diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..009cca4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +**Setup:** +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r setup/requirements.txt +``` + +**Run the app:** +```bash +source .venv/bin/activate +python3 app.py +# With options: +python3 app.py --file data/grid-1.vtk --port 1234 +``` + +**Format code:** +```bash +black app.py # or any other file +``` + +**Docker:** +```bash +docker build -t trame-simple-visualizer . +docker run -it --rm -p 8008:80 trame-simple-visualizer +``` + +There are no automated tests in this project. + +## Architecture + +The app is a [Trame](https://trame.readthedocs.io/) web application that serves an interactive 3D VTK visualization in a browser. It uses Vue2 + Vuetify for the UI and VTK for rendering. The primary use case is visualizing deal.II finite element meshes. + +**Data flow:** +1. On startup, `app.py` scans `data/` for `.vtk`/`.vtu` files and builds an initial VTK rendering context. +2. The Trame server exposes reactive state to the Vue2 frontend. Key state variables: + - `available_files` / `selected_file` — file picker + - `available_arrays` / `selected_array` — color-by picker; array values use the format `"__solid__"`, `"point:ArrayName"`, or `"cell:ArrayName"` + - `representation` — one of `"Surface"`, `"Surface with Edges"`, `"Wireframe"`, `"Points"` + - `show_boundary` / `has_boundary` — boundary cells visibility toggle and availability flag + - `show_scalar_bars` — toggle all scalar bar legend visibility + - `edit_mode` — boundary cell editing mode (selection, ID assignment, save) + - `error_message` — shown as an overlay alert +3. Each `@state.change(...)` callback in `app.py` calls the appropriate `vtk_pipeline` function and then `ctrl.view_update()` to push the new render to the browser. + +**Module responsibilities:** +- `app.py` — entry point, argument parsing, Trame server init, state management, `@state.change` callbacks, scalar bar lifecycle, edit mode interactor observer +- `vtk_pipeline.py` — VTK rendering logic (actor/mapper creation, coloring, representation, LUT building) +- `boundary_edit.py` — boundary cell extraction, interactive selection, BoundaryID assignment, save as .vtu. ManifoldID values are preserved but not edited. +- `file_utils.py` — format detection and `data/` folder scanning +- `ui.py` — Trame/Vuetify layout (toolbar, VTK viewport, edit mode drawer, error overlay) +- `constants.py` — string sentinels/prefixes, representation mode names, known array names, deal.II default ID values + +**VTK pipeline (`vtk_pipeline.py`):** + +`build_visualization(filename, renderer)` — clears the renderer, reads the file, splits the dataset by cell dimension into volume and boundary sub-datasets, builds actors for each, and pre-builds categorical LUTs. Returns a `VisualizationResult` namedtuple with fields: `vol_actor`, `vol_mapper`, `vol_dataset`, `vol_luts`, `bnd_actor`, `bnd_mapper`, `bnd_dataset`, `bnd_luts`, `full_dataset`. The `bnd_*` fields are `None`/empty when no lower-dimension cells exist. Coloring is applied by the caller (`app.py`) after this returns. + +`split_by_dimension(dataset)` — splits a mixed unstructured grid into volume cells (max dimension) and boundary cells (lower dimension) using `vtkExtractCells`. Returns `(vol_dataset, bnd_dataset)` where `bnd_dataset` may be `None`. + +`apply_coloring(actor, mapper, dataset, array_value, luts=None)` — sets mapper to solid color (`Tomato`) or enables scalar coloring. For arrays in `CATEGORICAL_CELL_ARRAYS` uses the pre-built categorical LUT; for all other arrays builds a continuous `vtkLookupTable`. Returns the active LUT (or `None` for solid color). + +`apply_representation(vol_actor, bnd_actor, representation)` — sets surface/wireframe/points mode; boundary cells always render as surface/lines except in Points mode. + +`build_categorical_lut(unique_ids)` — builds an indexed `vtkLookupTable` with `BREWER_QUALITATIVE_SET1` palette for categorical integer data (handles negative IDs via indexed lookup). Returns `(lut, index_map)`. + +**Boundary editing (`boundary_edit.py`):** + +`extract_all_boundary_subcells(full_dataset)` — finds exterior boundary sub-cells by iterating volume cell edges (2D) or faces (3D) and selecting those shared by exactly one volume cell. Also classifies cells in the file as volume vs boundary by dimension (same logic as `split_by_dimension` but returns index lists instead of extracted datasets). + +`build_merged_boundary_dataset(full_dataset, file_bnd_indices, extracted_subcells)` — builds a `vtkUnstructuredGrid` containing all boundary cells (exterior sub-cells + interior file boundary markers), with `MaterialID` and `ManifoldID` arrays. Exterior sub-cells that match file boundary cells inherit their IDs; others get defaults. + +`save_as_vtu(edit_state, output_path)` — reconstructs the full mesh (volume cells + edited boundary cells) and writes `.vtu`. + +In edit mode, left-click picks boundary cells via `vtkCellPicker`. The default interactor style's `LeftButtonPressEvent` observers are removed to prevent camera rotation on left-click; middle/right-click camera controls remain. + +**Important VTK patterns:** +- When modifying VTK array values directly (e.g. `arr.SetValue()`), call `dataset.Modified()` afterward to bump the MTime so downstream mappers re-render. +- State changes inside VTK observer callbacks (outside Trame's request cycle) require `state.flush()` to push updates to the browser. + +**File format detection (`file_utils.py`):** + +`.vtu` → `vtkXMLUnstructuredGridReader`. `.vtk` (legacy) → reads first 500 bytes to detect dataset type, selects appropriate reader. Falls back to `vtkUnstructuredGridReader`. + +**Adding a new VTK format:** add the extension to `supported_extensions` in `file_utils.py` and add reader selection logic in `detect_and_create_reader()`. diff --git a/app.py b/app.py index e2cf163..cb960a7 100644 --- a/app.py +++ b/app.py @@ -13,11 +13,26 @@ from vtk_pipeline import ( build_visualization, build_scalar_bar, + build_categorical_lut, get_available_arrays, apply_coloring, apply_representation, create_vtk_rendering_context, ) +from boundary_edit import ( + BoundaryEditState, + extract_all_boundary_subcells, + build_merged_boundary_dataset, + build_adjacency_graph, + compute_cell_normals, + create_boundary_actor, + create_selection_actor, + create_cell_picker, + handle_pick, + assign_id_to_selection, + update_selection_actor, + save_as_vtu, +) from ui import build_ui @@ -45,8 +60,12 @@ # Module-level pipeline references (updated whenever a file is loaded) _viz = None # VisualizationResult | None _active_coloring_bar = None # scalar bar tracking the current "Color by" selection +_active_lut = None # LUT currently used for the "Color by" coloring _bnd_coloring_bar = None # scalar bar for boundary IDs (only when MaterialID selected) +# Boundary editing state +_edit = BoundaryEditState() + DEFAULT_REPRESENTATION = REPR_SURFACE_EDGES available_files = get_vtk_files_from_data_folder() @@ -79,6 +98,17 @@ state.show_scalar_bars = True state.has_boundary = False +# Edit mode state +state.edit_mode = False +state.selection_count = 0 +state.assign_id_value = "0" +state.save_filename = "output" +state.save_status = "" +state.save_status_type = "success" +state.pick_mode = True +state.group_select = False +state.angle_threshold = 15 + # ----------------------------------------------------------------------------- # Build UI @@ -103,9 +133,28 @@ def _array_label(array_value): return array_value +def _update_bnd_coloring_bar(lut): + """Replace the boundary coloring bar with one built from *lut* (or remove it).""" + global _bnd_coloring_bar + + if _bnd_coloring_bar is not None: + renderer.RemoveActor(_bnd_coloring_bar) + _bnd_coloring_bar = None + if lut is not None: + _bnd_coloring_bar = build_scalar_bar( + lut, + "Boundary ID", + position=(0.05, 0.45), + width=0.08, + height=0.35, + ) + _bnd_coloring_bar.SetVisibility(1 if state.show_scalar_bars else 0) + renderer.AddActor(_bnd_coloring_bar) + + def _update_scalar_bars(active_lut, array_value): """Manage all scalar bars: the active coloring bar and the boundary ID bar.""" - global _active_coloring_bar, _bnd_coloring_bar + global _active_coloring_bar # --- Active coloring bar (tracks "Color by" selection) --- if _active_coloring_bar is not None: @@ -115,26 +164,16 @@ def _update_scalar_bars(active_lut, array_value): _active_coloring_bar = build_scalar_bar( active_lut, _array_label(array_value), - position=(0.82, 0.05), + position=(0.05, 0.05), width=0.08, height=0.35, ) renderer.AddActor(_active_coloring_bar) # --- Boundary coloring bar (only when MaterialID selected + boundary exists) --- - if _bnd_coloring_bar is not None: - renderer.RemoveActor(_bnd_coloring_bar) - _bnd_coloring_bar = None is_material_id = array_value == f"{CELL_PREFIX}{MATERIAL_ID_ARRAY}" - if is_material_id and _viz and _viz.bnd_luts.get(MATERIAL_ID_ARRAY): - _bnd_coloring_bar = build_scalar_bar( - _viz.bnd_luts[MATERIAL_ID_ARRAY], - "Boundary ID", - position=(0.82, 0.45), - width=0.08, - height=0.35, - ) - renderer.AddActor(_bnd_coloring_bar) + bnd_lut = _viz.bnd_luts.get(MATERIAL_ID_ARRAY) if is_material_id and _viz else None + _update_bnd_coloring_bar(bnd_lut) _apply_scalar_bar_visibility(state.show_scalar_bars) @@ -150,6 +189,151 @@ def _apply_scalar_bar_visibility(show): bar.SetVisibility(1 if show else 0) +def _setup_edit_infrastructure(): + """Extract boundary cells and create edit actors after a file is loaded.""" + _edit.clear() + if _viz is None: + return + + _edit.full_dataset = _viz.full_dataset + + vol_indices, bnd_indices, extracted_subcells = extract_all_boundary_subcells( + _viz.full_dataset + ) + _edit.vol_cell_indices = vol_indices + _edit.file_bnd_cell_indices = bnd_indices + + if not extracted_subcells: + print(" No exterior boundary sub-cells found for editing.") + return + + print(f" Extracted {len(extracted_subcells)} exterior boundary sub-cells") + print(f" ({len(bnd_indices)} were in the file)") + + _edit.merged_bnd_dataset = build_merged_boundary_dataset( + _viz.full_dataset, bnd_indices, extracted_subcells + ) + _edit.merged_bnd_actor, _edit.merged_bnd_mapper = create_boundary_actor( + _edit.merged_bnd_dataset + ) + _edit.selection_actor, _edit.selection_mapper = create_selection_actor() + _edit.picker = create_cell_picker(_edit.merged_bnd_actor) + _edit.adjacency = build_adjacency_graph(_edit.merged_bnd_dataset) + _edit.cell_normals = compute_cell_normals(_edit.merged_bnd_dataset) + + renderer.AddActor(_edit.merged_bnd_actor) + renderer.AddActor(_edit.selection_actor) + + +def _apply_edit_coloring(array_name=None): + """Apply categorical coloring to the merged boundary actor and update scalar bar.""" + if _edit.merged_bnd_dataset is None or _edit.merged_bnd_mapper is None: + return + if array_name is None: + array_name = MATERIAL_ID_ARRAY + + arr = _edit.merged_bnd_dataset.GetCellData().GetArray(array_name) + if arr is None: + return + + unique_ids = sorted( + set(int(arr.GetValue(j)) for j in range(arr.GetNumberOfTuples())) + ) + lut, _ = build_categorical_lut(unique_ids) + _edit.merged_bnd_mapper.ScalarVisibilityOn() + _edit.merged_bnd_mapper.SetScalarModeToUseCellFieldData() + _edit.merged_bnd_mapper.SelectColorArray(array_name) + _edit.merged_bnd_mapper.SetLookupTable(lut) + _edit.merged_bnd_mapper.UseLookupTableScalarRangeOn() + + _update_bnd_coloring_bar(lut) + + +# --------------------------------------------------------------------------- +# Interactor observer for picking in edit mode +# --------------------------------------------------------------------------- + + +def _on_left_button_press(obj, event): + """Interactor observer: pick boundary cells or forward to camera rotation.""" + if not state.edit_mode: + return + if not state.pick_mode: + obj.GetInteractorStyle().OnLeftButtonDown() + return + x, y = obj.GetEventPosition() + cell_id = handle_pick( + x, + y, + renderer, + _edit, + group_select=state.group_select, + angle_threshold=state.angle_threshold, + ) + if cell_id is not None: + state.selection_count = len(_edit.selection_set) + state.flush() + renderWindow.Render() + ctrl.view_update() + + +def _on_left_button_release(obj, event): + """Forward left-button release to camera style when in rotation mode.""" + if not state.edit_mode or state.pick_mode: + return + obj.GetInteractorStyle().OnLeftButtonUp() + + +_default_interactor_style = None # saved on first edit-mode entry + + +def _install_pick_observer(): + """Add LeftButtonPressEvent observer and swap to a pick-friendly style.""" + global _default_interactor_style + + if _edit._observer_tag is not None: + return + + # Save the original interactor style so we can restore it later + _default_interactor_style = renderWindowInteractor.GetInteractorStyle() + + # Replace with a plain trackball camera style for middle/right-click + # camera control (pan, zoom). + from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera + + style = vtkInteractorStyleTrackballCamera() + renderWindowInteractor.SetInteractorStyle(style) + + # Remove the style's left-button observers so it won't start camera + # rotation. We handle left-click entirely in our own observers. + renderWindowInteractor.RemoveObservers("LeftButtonPressEvent") + renderWindowInteractor.RemoveObservers("LeftButtonReleaseEvent") + + _edit._observer_tag = renderWindowInteractor.AddObserver( + "LeftButtonPressEvent", _on_left_button_press + ) + _edit._release_observer_tag = renderWindowInteractor.AddObserver( + "LeftButtonReleaseEvent", _on_left_button_release + ) + + +def _remove_pick_observer(): + """Remove picking observer and restore default interactor style.""" + global _default_interactor_style + + if _edit._observer_tag is not None: + renderWindowInteractor.RemoveObserver(_edit._observer_tag) + _edit._observer_tag = None + if _edit._release_observer_tag is not None: + renderWindowInteractor.RemoveObserver(_edit._release_observer_tag) + _edit._release_observer_tag = None + + # Restore the original switch-based interactor style + if _default_interactor_style is not None: + renderWindowInteractor.SetInteractorStyle(_default_interactor_style) + _default_interactor_style = None + + # ----------------------------------------------------------------------------- # State Callbacks # ----------------------------------------------------------------------------- @@ -158,11 +342,16 @@ def _apply_scalar_bar_visibility(show): @state.change("selected_file") def on_file_change(selected_file, **kwargs): """Reload pipeline and reset coloring/representation when file changes.""" - global _viz + global _viz, _active_lut if selected_file and os.path.exists(selected_file): try: print(f"\nLoading file: {selected_file}") + + # Exit edit mode if active + if state.edit_mode: + state.edit_mode = False + _viz = build_visualization(selected_file, renderer) arrays = get_available_arrays(_viz.full_dataset) @@ -175,7 +364,7 @@ def on_file_change(selected_file, **kwargs): arrays[1]["value"] if len(arrays) > 1 else ARRAY_SOLID, ) - active_lut = apply_coloring( + _active_lut = apply_coloring( _viz.vol_actor, _viz.vol_mapper, _viz.vol_dataset, @@ -191,13 +380,20 @@ def on_file_change(selected_file, **kwargs): _viz.bnd_luts, ) apply_representation(_viz.vol_actor, _viz.bnd_actor, state.representation) - _update_scalar_bars(active_lut, default_array) + _update_scalar_bars(_active_lut, default_array) _apply_boundary_visibility(state.show_boundary) + # Set up boundary editing infrastructure + _setup_edit_infrastructure() + state.available_arrays = arrays state.selected_array = default_array - state.has_boundary = _viz.bnd_actor is not None + state.has_boundary = ( + _viz.bnd_actor is not None or _edit.merged_bnd_dataset is not None + ) state.error_message = "" + state.selection_count = 0 + state.save_status = "" renderWindow.Render() ctrl.view_update() @@ -212,8 +408,9 @@ def on_file_change(selected_file, **kwargs): @state.change("selected_array") def on_array_change(selected_array, **kwargs): """Update coloring when the user picks a different array.""" + global _active_lut if _viz is not None: - active_lut = apply_coloring( + _active_lut = apply_coloring( _viz.vol_actor, _viz.vol_mapper, _viz.vol_dataset, @@ -228,7 +425,7 @@ def on_array_change(selected_array, **kwargs): selected_array, _viz.bnd_luts, ) - _update_scalar_bars(active_lut, selected_array) + _update_scalar_bars(_active_lut, selected_array) renderWindow.Render() ctrl.view_update() @@ -260,6 +457,140 @@ def on_show_scalar_bars_change(show_scalar_bars, **kwargs): ctrl.view_update() +@state.change("edit_mode") +def on_edit_mode_change(edit_mode, **kwargs): + """Toggle between view mode and boundary edit mode.""" + if _edit.merged_bnd_actor is None: + if edit_mode: + state.edit_mode = False + state.error_message = "No boundary cells available for editing" + return + + if edit_mode: + # Enter edit mode + state.pick_mode = True + _edit.merged_bnd_actor.SetVisibility(1) + if _viz and _viz.bnd_actor: + _viz.bnd_actor.SetVisibility(0) + _apply_edit_coloring(MATERIAL_ID_ARRAY) + _install_pick_observer() + else: + # Exit edit mode + _remove_pick_observer() + _edit.merged_bnd_actor.SetVisibility(0) + _edit.selection_actor.SetVisibility(0) + _edit.selection_set.clear() + state.selection_count = 0 + if _viz and _viz.bnd_actor: + _viz.bnd_actor.SetVisibility(1 if state.show_boundary else 0) + # Restore boundary scalar bar from the saved view-mode LUT + _update_scalar_bars(_active_lut, state.selected_array) + + renderWindow.Render() + ctrl.view_update() + + +# ----------------------------------------------------------------------------- +# Controller methods (called from UI buttons) +# ----------------------------------------------------------------------------- + + +@ctrl.add("clear_selection") +def clear_selection(): + """Clear all selected boundary cells.""" + _edit.selection_set.clear() + update_selection_actor( + _edit.selection_set, + _edit.merged_bnd_dataset, + _edit.selection_actor, + _edit.selection_mapper, + ) + state.selection_count = 0 + renderWindow.Render() + ctrl.view_update() + + +@ctrl.add("select_all") +def select_all(): + """Select all boundary cells.""" + if _edit.merged_bnd_dataset is not None: + n = _edit.merged_bnd_dataset.GetNumberOfCells() + _edit.selection_set = set(range(n)) + update_selection_actor( + _edit.selection_set, + _edit.merged_bnd_dataset, + _edit.selection_actor, + _edit.selection_mapper, + ) + state.selection_count = n + renderWindow.Render() + ctrl.view_update() + + +@ctrl.add("assign_id") +def assign_id(): + """Assign the chosen ID value to all selected boundary cells.""" + if not _edit.selection_set or _edit.merged_bnd_dataset is None: + return + + try: + value = int(state.assign_id_value) + except (ValueError, TypeError): + state.error_message = "Invalid ID value — must be an integer" + return + + assign_id_to_selection(_edit, MATERIAL_ID_ARRAY, value) + + # Rebuild coloring for the merged boundary actor + _apply_edit_coloring(MATERIAL_ID_ARRAY) + + # Clear selection after assignment + _edit.selection_set.clear() + update_selection_actor( + _edit.selection_set, + _edit.merged_bnd_dataset, + _edit.selection_actor, + _edit.selection_mapper, + ) + state.selection_count = 0 + + renderWindow.Render() + ctrl.view_update() + + +@ctrl.add("save_vtu") +def save_vtu(): + """Save the modified mesh as .vtu.""" + if _edit.full_dataset is None or _edit.merged_bnd_dataset is None: + state.save_status = "No data to save" + state.save_status_type = "error" + return + + try: + filename = state.save_filename.strip() + if not filename: + filename = "output" + if not filename.endswith(".vtu"): + filename += ".vtu" + output_path = os.path.join(CURRENT_DIRECTORY, "data", filename) + + save_as_vtu(_edit, output_path) + + state.save_status = f"Saved to data/{filename}" + state.save_status_type = "success" + + # Refresh file list so the new file appears in the dropdown + state.available_files = get_vtk_files_from_data_folder() + print(f" Saved to {output_path}") + except Exception as e: + state.save_status = f"Error: {str(e)}" + state.save_status_type = "error" + print(f"Save error: {e}") + import traceback + + traceback.print_exc() + + # ----------------------------------------------------------------------------- # Main # ----------------------------------------------------------------------------- diff --git a/boundary_edit.py b/boundary_edit.py new file mode 100644 index 0000000..552e0c5 --- /dev/null +++ b/boundary_edit.py @@ -0,0 +1,550 @@ +""" +Boundary cell editing: extraction, selection, ID assignment, and save. + +Provides the logic for extracting all exterior boundary cells from a deal.II +mesh (including those not explicitly present in the file), selecting them +interactively, assigning BoundaryID values, and saving the result as .vtu. +ManifoldID values are preserved (read and written back) but not edited here. +""" + +from vtkmodules.vtkCommonCore import vtkIdList, vtkIntArray +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid, vtkCellTypes +from vtkmodules.vtkFiltersCore import vtkExtractCells +from vtkmodules.vtkIOXML import vtkXMLUnstructuredGridWriter +from vtkmodules.vtkRenderingCore import vtkActor, vtkDataSetMapper + +from constants import ( + MATERIAL_ID_ARRAY, + MANIFOLD_ID_ARRAY, + BOUNDARY_ID_DEFAULT, + MANIFOLD_ID_DEFAULT, +) + + +# --------------------------------------------------------------------------- +# State container +# --------------------------------------------------------------------------- + + +class BoundaryEditState: + """Holds all mutable state for boundary cell editing.""" + + def __init__(self): + self.full_dataset = None + self.vol_cell_indices = [] + self.file_bnd_cell_indices = [] + self.merged_bnd_dataset = None + self.merged_bnd_actor = None + self.merged_bnd_mapper = None + self.selection_set = set() + self.selection_actor = None + self.selection_mapper = None + self.picker = None + self.adjacency = None + self.cell_normals = None + self._observer_tag = None + self._release_observer_tag = None + + def clear(self): + self.full_dataset = None + self.vol_cell_indices = [] + self.file_bnd_cell_indices = [] + self.merged_bnd_dataset = None + self.merged_bnd_actor = None + self.merged_bnd_mapper = None + self.selection_set = set() + self.selection_actor = None + self.selection_mapper = None + self.picker = None + self.adjacency = None + self.cell_normals = None + self._observer_tag = None + self._release_observer_tag = None + + +# --------------------------------------------------------------------------- +# Boundary extraction +# --------------------------------------------------------------------------- + + +def extract_all_boundary_subcells(full_dataset): + """ + Extract all exterior boundary sub-cells from the full dataset. + + In 2D meshes the boundary sub-cells are *edges* (lines); in 3D they are + *faces* (quads / triangles). A sub-cell is on the geometric boundary + when it is shared by exactly one volume cell. + + Returns + ------- + vol_cell_indices : list[int] + Cell indices in *full_dataset* that are volume cells (max dimension). + file_bnd_cell_indices : list[int] + Cell indices in *full_dataset* that are lower-dimension cells already + present in the file. + boundary_subcells : list[dict] + Each dict has keys ``point_ids`` (tuple[int]), ``cell_type`` (int), + and ``key`` (frozenset[int]). + """ + n = full_dataset.GetNumberOfCells() + if n == 0: + return [], [], [] + + max_dim = max(full_dataset.GetCell(i).GetCellDimension() for i in range(n)) + + vol_indices = [] + bnd_indices = [] + for i in range(n): + if full_dataset.GetCell(i).GetCellDimension() == max_dim: + vol_indices.append(i) + else: + bnd_indices.append(i) + + # Count how many volume cells share each sub-cell (edge in 2D, face in 3D) + subcell_count = {} # frozenset(pt_ids) -> count + subcell_info = {} # frozenset(pt_ids) -> (cell_type, ordered_pt_ids) + + for ci in vol_indices: + cell = full_dataset.GetCell(ci) + if max_dim == 2: + n_sub = cell.GetNumberOfEdges() + get_sub = cell.GetEdge + elif max_dim == 3: + n_sub = cell.GetNumberOfFaces() + get_sub = cell.GetFace + else: + continue + + for s in range(n_sub): + sub = get_sub(s) + ordered = tuple( + int(sub.GetPointId(p)) for p in range(sub.GetNumberOfPoints()) + ) + key = frozenset(ordered) + subcell_count[key] = subcell_count.get(key, 0) + 1 + if key not in subcell_info: + subcell_info[key] = (sub.GetCellType(), ordered) + + # Exterior boundary = shared by exactly 1 volume cell + boundary_subcells = [] + for key, count in subcell_count.items(): + if count == 1: + cell_type, ordered = subcell_info[key] + boundary_subcells.append( + { + "point_ids": ordered, + "cell_type": cell_type, + "key": key, + } + ) + + return vol_indices, bnd_indices, boundary_subcells + + +# --------------------------------------------------------------------------- +# Merged boundary dataset +# --------------------------------------------------------------------------- + + +def build_merged_boundary_dataset(full_dataset, file_bnd_indices, extracted_subcells): + """ + Build a ``vtkUnstructuredGrid`` containing *all* boundary cells. + + This includes: + - All exterior sub-cells extracted from volume cells (edges in 2D, faces + in 3D). Those that match a file boundary cell inherit its IDs; + otherwise they receive default values. + - File boundary cells that are *interior* (shared by 2 volume cells) — + these are deal.II interior boundary markers and must be preserved. + + The returned grid shares the same point array as *full_dataset* so point + IDs are consistent. + """ + # Build lookup from file boundary cells + mat_arr = full_dataset.GetCellData().GetArray(MATERIAL_ID_ARRAY) + man_arr = full_dataset.GetCellData().GetArray(MANIFOLD_ID_ARRAY) + + # file_bnd_info: list of (key, cell_type, ordered_pts, mat_val, man_val) + file_bnd_info = [] + file_bnd_keys = set() + for ci in file_bnd_indices: + cell = full_dataset.GetCell(ci) + ordered = tuple( + int(cell.GetPointId(p)) for p in range(cell.GetNumberOfPoints()) + ) + key = frozenset(ordered) + mat_val = int(mat_arr.GetValue(ci)) if mat_arr else BOUNDARY_ID_DEFAULT + man_val = int(man_arr.GetValue(ci)) if man_arr else MANIFOLD_ID_DEFAULT + file_bnd_info.append((key, cell.GetCellType(), ordered, mat_val, man_val)) + file_bnd_keys.add(key) + + # Lookup for matching extracted subcells against file boundary cells + file_ids_lookup = {info[0]: (info[3], info[4]) for info in file_bnd_info} + + # Extracted keys (exterior sub-cells) + extracted_keys = {sc["key"] for sc in extracted_subcells} + + # Interior file boundary cells = in file but not among extracted exterior + interior_bnd = [info for info in file_bnd_info if info[0] not in extracted_keys] + + total = len(extracted_subcells) + len(interior_bnd) + + # Build the merged grid + bnd_grid = vtkUnstructuredGrid() + bnd_grid.SetPoints(full_dataset.GetPoints()) + bnd_grid.Allocate(total) + + mat_id_arr = vtkIntArray() + mat_id_arr.SetName(MATERIAL_ID_ARRAY) + man_id_arr = vtkIntArray() + man_id_arr.SetName(MANIFOLD_ID_ARRAY) + + # 1) Exterior sub-cells (from volume cell edges/faces) + for sc in extracted_subcells: + id_list = vtkIdList() + for pid in sc["point_ids"]: + id_list.InsertNextId(pid) + bnd_grid.InsertNextCell(sc["cell_type"], id_list) + + if sc["key"] in file_ids_lookup: + mat_val, man_val = file_ids_lookup[sc["key"]] + else: + mat_val = BOUNDARY_ID_DEFAULT + man_val = MANIFOLD_ID_DEFAULT + + mat_id_arr.InsertNextValue(mat_val) + man_id_arr.InsertNextValue(man_val) + + # 2) Interior file boundary cells (deal.II interior markers) + for key, cell_type, ordered, mat_val, man_val in interior_bnd: + id_list = vtkIdList() + for pid in ordered: + id_list.InsertNextId(pid) + bnd_grid.InsertNextCell(cell_type, id_list) + mat_id_arr.InsertNextValue(mat_val) + man_id_arr.InsertNextValue(man_val) + + bnd_grid.GetCellData().AddArray(mat_id_arr) + bnd_grid.GetCellData().AddArray(man_id_arr) + + return bnd_grid + + +# --------------------------------------------------------------------------- +# Adjacency and normals for group selection +# --------------------------------------------------------------------------- + + +def build_adjacency_graph(bnd_dataset): + """Build a cell adjacency graph for the boundary dataset. + + Two cells are adjacent if they share an edge (2 common points for 2-D + cells) or a point (for 1-D line cells). + + Returns ``dict[int, set[int]]`` mapping each cell ID to its neighbors. + """ + n_cells = bnd_dataset.GetNumberOfCells() + edge_to_cells = {} + + for ci in range(n_cells): + cell = bnd_dataset.GetCell(ci) + n_pts = cell.GetNumberOfPoints() + pts = [int(cell.GetPointId(p)) for p in range(n_pts)] + + if cell.GetCellDimension() >= 2: + # Adjacency via shared edges (pairs of consecutive points) + for i in range(n_pts): + edge = frozenset((pts[i], pts[(i + 1) % n_pts])) + edge_to_cells.setdefault(edge, []).append(ci) + elif cell.GetCellDimension() == 1: + # Line cells: adjacency via shared points + for pid in pts: + edge_to_cells.setdefault(pid, []).append(ci) + + adjacency = {ci: set() for ci in range(n_cells)} + for cells_sharing in edge_to_cells.values(): + for i in range(len(cells_sharing)): + for j in range(i + 1, len(cells_sharing)): + adjacency[cells_sharing[i]].add(cells_sharing[j]) + adjacency[cells_sharing[j]].add(cells_sharing[i]) + + return adjacency + + +def compute_cell_normals(bnd_dataset): + """Compute a unit normal vector for each cell in the boundary dataset. + + For 2-D cells (triangles, quads): cross product of two edge vectors. + For 1-D cells (lines): 90-degree perpendicular in the XY plane. + + Returns a list of ``(nx, ny, nz)`` tuples, one per cell. + """ + import math + + normals = [] + points = bnd_dataset.GetPoints() + + for ci in range(bnd_dataset.GetNumberOfCells()): + cell = bnd_dataset.GetCell(ci) + n_pts = cell.GetNumberOfPoints() + + if cell.GetCellDimension() >= 2 and n_pts >= 3: + p0 = points.GetPoint(cell.GetPointId(0)) + p1 = points.GetPoint(cell.GetPointId(1)) + p2 = points.GetPoint(cell.GetPointId(2)) + + v1 = (p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]) + v2 = (p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]) + + nx = v1[1] * v2[2] - v1[2] * v2[1] + ny = v1[2] * v2[0] - v1[0] * v2[2] + nz = v1[0] * v2[1] - v1[1] * v2[0] + + length = math.sqrt(nx * nx + ny * ny + nz * nz) + if length > 1e-12: + normals.append((nx / length, ny / length, nz / length)) + else: + normals.append((0.0, 0.0, 1.0)) + + elif cell.GetCellDimension() == 1 and n_pts >= 2: + p0 = points.GetPoint(cell.GetPointId(0)) + p1 = points.GetPoint(cell.GetPointId(1)) + dx, dy = p1[0] - p0[0], p1[1] - p0[1] + length = math.sqrt(dx * dx + dy * dy) + if length > 1e-12: + normals.append((-dy / length, dx / length, 0.0)) + else: + normals.append((0.0, 1.0, 0.0)) + else: + normals.append((0.0, 0.0, 1.0)) + + return normals + + +def flood_select(start_cell, adjacency, normals, angle_threshold_deg): + """BFS flood-fill selecting connected cells within an angle threshold. + + Expands from *start_cell* to neighbors whose normal angle relative to + the **start cell's** normal is within *angle_threshold_deg* degrees. + Uses ``abs(dot)`` to handle inconsistent winding order. + + Returns a ``set`` of selected cell IDs (always includes *start_cell*). + """ + import math + from collections import deque + + cos_threshold = math.cos(math.radians(angle_threshold_deg)) + ref = normals[start_cell] + + visited = {start_cell} + queue = deque([start_cell]) + + while queue: + current = queue.popleft() + for neighbor in adjacency.get(current, set()): + if neighbor in visited: + continue + n = normals[neighbor] + dot = ref[0] * n[0] + ref[1] * n[1] + ref[2] * n[2] + if abs(dot) >= cos_threshold: + visited.add(neighbor) + queue.append(neighbor) + + return visited + + +# --------------------------------------------------------------------------- +# Actors +# --------------------------------------------------------------------------- + + +def create_boundary_actor(bnd_dataset): + """Create actor + mapper for the merged boundary dataset (initially hidden).""" + mapper = vtkDataSetMapper() + mapper.SetInputData(bnd_dataset) + mapper.ScalarVisibilityOff() + + actor = vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetLineWidth(3.0) + actor.GetProperty().SetColor(0.2, 0.6, 1.0) + actor.SetVisibility(0) + + return actor, mapper + + +def create_selection_actor(): + """Create an initially-hidden actor for highlighting selected cells.""" + mapper = vtkDataSetMapper() + mapper.ScalarVisibilityOff() + + actor = vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(1.0, 1.0, 0.0) # yellow + actor.GetProperty().SetLineWidth(5.0) + actor.GetProperty().SetPointSize(8) + actor.SetVisibility(0) + + return actor, mapper + + +def update_selection_actor( + selection_set, bnd_dataset, selection_actor, selection_mapper +): + """Rebuild the selection highlight actor from the current selection set.""" + if not selection_set: + selection_actor.SetVisibility(0) + return + + id_list = vtkIdList() + for idx in sorted(selection_set): + id_list.InsertNextId(idx) + + ext = vtkExtractCells() + ext.SetInputData(bnd_dataset) + ext.SetCellList(id_list) + ext.Update() + + selection_mapper.SetInputData(ext.GetOutput()) + selection_actor.SetVisibility(1) + + +# --------------------------------------------------------------------------- +# Picking +# --------------------------------------------------------------------------- + + +def create_cell_picker(bnd_actor): + """Create a vtkCellPicker restricted to *bnd_actor*.""" + from vtkmodules.vtkRenderingCore import vtkCellPicker + + picker = vtkCellPicker() + picker.SetTolerance(0.005) + picker.PickFromListOn() + picker.InitializePickList() + picker.AddPickList(bnd_actor) + return picker + + +def handle_pick(x, y, renderer, edit_state, group_select=False, angle_threshold=15.0): + """ + Pick at screen coordinates *(x, y)* and toggle the cell in *selection_set*. + + When *group_select* is ``True`` and adjacency/normals are available, a + flood-fill selects (or deselects) all connected cells within + *angle_threshold* degrees of the picked cell's normal. + + Returns the cell ID that was picked, or ``None`` if nothing was hit. + """ + result = edit_state.picker.Pick(x, y, 0, renderer) + if result == 0: + return None + + cell_id = edit_state.picker.GetCellId() + if cell_id < 0: + return None + + if group_select and edit_state.adjacency and edit_state.cell_normals: + group = flood_select( + cell_id, edit_state.adjacency, edit_state.cell_normals, angle_threshold + ) + if cell_id in edit_state.selection_set: + edit_state.selection_set -= group + else: + edit_state.selection_set |= group + else: + if cell_id in edit_state.selection_set: + edit_state.selection_set.discard(cell_id) + else: + edit_state.selection_set.add(cell_id) + + update_selection_actor( + edit_state.selection_set, + edit_state.merged_bnd_dataset, + edit_state.selection_actor, + edit_state.selection_mapper, + ) + return cell_id + + +# --------------------------------------------------------------------------- +# ID assignment +# --------------------------------------------------------------------------- + + +def assign_id_to_selection(edit_state, array_name, value): + """Set *value* on every selected cell in *array_name*.""" + arr = edit_state.merged_bnd_dataset.GetCellData().GetArray(array_name) + if arr is None: + return + + for cell_idx in edit_state.selection_set: + arr.SetValue(cell_idx, value) + + # Bump MTime so the mapper knows the data changed (SetValue bypasses the pipeline). + edit_state.merged_bnd_dataset.Modified() + + +# --------------------------------------------------------------------------- +# Save +# --------------------------------------------------------------------------- + + +def save_as_vtu(edit_state, output_path): + """ + Reconstruct a full mesh (volume + edited boundary cells) and write *.vtu*. + """ + full_ds = edit_state.full_dataset + merged_bnd = edit_state.merged_bnd_dataset + + output = vtkUnstructuredGrid() + output.SetPoints(full_ds.GetPoints()) + + n_vol = len(edit_state.vol_cell_indices) + n_bnd = merged_bnd.GetNumberOfCells() + output.Allocate(n_vol + n_bnd) + + mat_arr = vtkIntArray() + mat_arr.SetName(MATERIAL_ID_ARRAY) + man_arr = vtkIntArray() + man_arr.SetName(MANIFOLD_ID_ARRAY) + + # Volume cells + orig_mat = full_ds.GetCellData().GetArray(MATERIAL_ID_ARRAY) + orig_man = full_ds.GetCellData().GetArray(MANIFOLD_ID_ARRAY) + + for ci in edit_state.vol_cell_indices: + cell = full_ds.GetCell(ci) + id_list = vtkIdList() + for p in range(cell.GetNumberOfPoints()): + id_list.InsertNextId(cell.GetPointId(p)) + output.InsertNextCell(cell.GetCellType(), id_list) + mat_arr.InsertNextValue(int(orig_mat.GetValue(ci)) if orig_mat else 0) + man_arr.InsertNextValue( + int(orig_man.GetValue(ci)) if orig_man else MANIFOLD_ID_DEFAULT + ) + + # Boundary cells (with edits) + bnd_mat = merged_bnd.GetCellData().GetArray(MATERIAL_ID_ARRAY) + bnd_man = merged_bnd.GetCellData().GetArray(MANIFOLD_ID_ARRAY) + + for i in range(n_bnd): + cell = merged_bnd.GetCell(i) + id_list = vtkIdList() + for p in range(cell.GetNumberOfPoints()): + id_list.InsertNextId(cell.GetPointId(p)) + output.InsertNextCell(cell.GetCellType(), id_list) + mat_arr.InsertNextValue(int(bnd_mat.GetValue(i))) + man_arr.InsertNextValue(int(bnd_man.GetValue(i))) + + output.GetCellData().AddArray(mat_arr) + output.GetCellData().AddArray(man_arr) + + # Preserve point data arrays (e.g. solution) + for i in range(full_ds.GetPointData().GetNumberOfArrays()): + output.GetPointData().AddArray(full_ds.GetPointData().GetArray(i)) + + writer = vtkXMLUnstructuredGridWriter() + writer.SetFileName(output_path) + writer.SetInputData(output) + writer.Write() diff --git a/constants.py b/constants.py index 0af0078..3b8436a 100644 --- a/constants.py +++ b/constants.py @@ -15,3 +15,7 @@ # Cell arrays that should use categorical (indexed) coloring instead of continuous CATEGORICAL_CELL_ARRAYS = [MATERIAL_ID_ARRAY, MANIFOLD_ID_ARRAY] + +# deal.II default ID values +BOUNDARY_ID_DEFAULT = 0 +MANIFOLD_ID_DEFAULT = -1 diff --git a/ui.py b/ui.py index f7dbe90..9ab3004 100644 --- a/ui.py +++ b/ui.py @@ -25,8 +25,6 @@ def build_ui(server, renderWindow): style="max-width: 200px;", classes="mr-2", ) - # with vuetify.Template(v_slot_extension=""): - # vuetify.VSpacer() vuetify.VSelect( v_model=("selected_array",), items=("available_arrays",), @@ -54,7 +52,9 @@ def build_ui(server, renderWindow): # ) vuetify.VSelect( v_model=("representation",), - items=([REPR_SURFACE, REPR_SURFACE_EDGES, REPR_WIREFRAME, REPR_POINTS],), + items=( + [REPR_SURFACE, REPR_SURFACE_EDGES, REPR_WIREFRAME, REPR_POINTS], + ), label="Representation", hide_details=True, dense=True, @@ -63,6 +63,17 @@ def build_ui(server, renderWindow): classes="mr-2", ) + # Edit mode toggle + vuetify.VBtn( + "Edit Mode", + small=True, + outlined=("!edit_mode",), + color=("edit_mode ? 'primary' : ''",), + click="edit_mode = !edit_mode", + classes="mr-2", + v_show=("has_boundary",), + ) + with layout.content: with vuetify.VContainer( fluid=True, @@ -80,6 +91,163 @@ def build_ui(server, renderWindow): style="position: absolute; top: 10px; left: 10px; right: 10px; z-index: 1000;", ) + # Edit mode side panel + with vuetify.VNavigationDrawer( + v_model=("edit_mode",), + right=True, + absolute=True, + width="260", + style="z-index: 5;", + ): + with vuetify.VList(dense=True): + + vuetify.VSubheader("Interaction Mode") + with vuetify.VListItem(): + with vuetify.VListItemContent(): + with vuetify.VRow(dense=True, classes="px-2"): + with vuetify.VCol(cols=6): + vuetify.VBtn( + "Pick", + small=True, + block=True, + color=("pick_mode ? 'primary' : ''",), + outlined=("!pick_mode",), + click="pick_mode = true", + ) + with vuetify.VCol(cols=6): + vuetify.VBtn( + "Rotate", + small=True, + block=True, + color=("!pick_mode ? 'primary' : ''",), + outlined=("pick_mode",), + click="pick_mode = false", + ) + vuetify.VDivider(classes="my-2") + vuetify.VSubheader("Selection") + with vuetify.VListItem(): + with vuetify.VListItemContent(): + vuetify.VListItemTitle( + "{{ selection_count }} cells selected" + ) + + with vuetify.VListItem(): + with vuetify.VListItemContent(): + with vuetify.VRow(dense=True, classes="px-2"): + with vuetify.VCol(cols=6): + vuetify.VBtn( + "Clear", + small=True, + outlined=True, + block=True, + click=ctrl.clear_selection, + disabled=("selection_count === 0",), + ) + with vuetify.VCol(cols=6): + vuetify.VBtn( + "Select All", + small=True, + outlined=True, + block=True, + click=ctrl.select_all, + ) + + with vuetify.VListItem( + dense=True, + ): + with vuetify.VListItemContent( + classes="pt-0", + ): + vuetify.VSwitch( + v_model=("group_select",), + label="Select Flat Region", + hide_details=True, + dense=True, + classes="pl-2", + ) + with vuetify.VListItem(dense=True): + with vuetify.VRow( + dense=True, + align="center", + no_gutters=True, + classes="px-2", + ): + with vuetify.VCol(): + vuetify.VSlider( + v_model=("angle_threshold",), + label="Angle", + min=0, + max=90, + step=1, + hide_details=True, + dense=True, + disabled=("!group_select",), + ) + with vuetify.VCol(cols="auto"): + vuetify.VChip( + "{{ angle_threshold }}°", + x_small=True, + disabled=("!group_select",), + ) + + vuetify.VDivider(classes="my-2") + vuetify.VSubheader("Assign Boundary ID") + + with vuetify.VListItem(): + with vuetify.VListItemContent(): + vuetify.VTextField( + v_model=("assign_id_value",), + label="Value", + type="number", + dense=True, + outlined=True, + hide_details=True, + ) + + with vuetify.VListItem(): + with vuetify.VListItemContent(): + vuetify.VBtn( + "Assign to Selected", + small=True, + color="primary", + block=True, + click=ctrl.assign_id, + disabled=("selection_count === 0",), + ) + + vuetify.VDivider(classes="my-2") + vuetify.VSubheader("Save") + + with vuetify.VListItem(): + with vuetify.VListItemContent(): + vuetify.VTextField( + v_model=("save_filename",), + label="Filename", + dense=True, + outlined=True, + hide_details=True, + suffix=".vtu", + ) + + with vuetify.VListItem(): + with vuetify.VListItemContent(): + vuetify.VBtn( + "Save as .vtu", + small=True, + color="success", + block=True, + click=ctrl.save_vtu, + ) + + with vuetify.VListItem(v_show=("save_status",)): + with vuetify.VListItemContent(): + vuetify.VAlert( + type=("save_status_type",), + dense=True, + children=["{{ save_status }}"], + classes="ma-0", + ) + # VTK view view = vtk.VtkRemoteView( renderWindow,