跳转至

API Reference

PolyscopeApp

Source code in yumo2/app.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
class PolyscopeApp:
    def __init__(self, config: Config, root_dir: Path | None = None, settings: Settings | None = None):
        self.config = config
        self.root_dir = root_dir or Path.cwd()

        initial_profile: str
        initial_settings: Settings
        if settings is not None:
            initial_profile = "external"
            initial_settings = settings
        else:
            initial_profile, initial_settings = load_active_profile(self.root_dir)

        self._ps: Any = None
        self._psim: Any = None
        self._mesh: Any = None
        self._loaded_cmaps: dict[str, str] = {}
        self._available_colormaps = list(BUILTIN_COLORMAPS)
        self._available_materials = list(BUILTIN_MATERIALS)

        self.active_profile: str
        self.settings: Settings
        self._session: SessionContext
        self.snapshot = Snapshot(self)
        self.scope = Scope(self)
        self.picker = Picker(self)

        self._reset_session_context(initial_settings, active_profile=initial_profile)

    # ── Public API ───────────────────────────────────────────────────────────
    def run(self) -> None:
        """Launch the GUI and drive the full application lifecycle."""
        import polyscope as ps
        import polyscope.imgui as psim

        self._ensure_default_imgui_ini()
        self._ps = ps
        self._psim = psim

        self.pre_load_hook()
        self._load_mesh_and_uv()
        self._load_data()
        self.post_load_hook()

        self._init_polyscope()
        self.post_polyscope_init_hook()

        self._setup_scene()
        self.post_scene_setup_hook()

        ps.set_user_callback(self.callback)

        try:
            ps.show()
        finally:
            self.scope.cleanup()
            self.snapshot.cleanup()

    @property
    def fps(self) -> float:
        if self._psim is None:
            return 0.0
        return float(self._psim.GetIO().Framerate)

    @freq_time_profiler("callback")
    def callback(self) -> None:  # pragma: no cover - Polyscope callback
        self._ui_info_section()
        self._ui_profile_section()
        self._ui_appearance_section()
        self._ui_processing_section()
        self.snapshot.ui()
        self.scope.ui()
        self.picker.ui()

    # ── Extension Hooks ──────────────────────────────────────────────────────
    def pre_load_hook(self) -> None:
        """Public extension point before any data or Polyscope state is loaded."""

    def post_load_hook(self) -> None:
        """Public extension point after mesh/data load, before Polyscope init."""

    def post_polyscope_init_hook(self) -> None:
        """Public extension point after Polyscope init, before scene setup."""

    def post_scene_setup_hook(self) -> None:
        """Public extension point after scene setup, before entering the UI loop."""

    # ── Internal Helpers ─────────────────────────────────────────────────────
    def _default_profile_name(self) -> str:
        return datetime.now().strftime("%y%m%d-%H%M%S")

    def _default_imgui_ini_path(self) -> Path:
        return DEFAULT_IMGUI_INI

    def _ensure_default_imgui_ini(self) -> None:
        target = Path.cwd() / "imgui.ini"
        if target.exists():
            return
        shutil.copyfile(self._default_imgui_ini_path(), target)
        logger.debug("default_imgui_ini_copied", source=str(self._default_imgui_ini_path()), target=str(target))

    def _log_settings_changed(self, updates: dict[str, object]) -> None:
        changes = {
            key: {"from": getattr(self.settings, key), "to": value}
            for key, value in updates.items()
            if getattr(self.settings, key) != value
        }
        if changes:
            logger.debug("settings_changed", changes=changes)

    # ── Pipeline stages ──────────────────────────────────────────────────────

    @time_profiler("load_mesh_and_uv")
    @mem_profiler("load_mesh_and_uv")
    def _load_mesh_and_uv(self) -> None:
        """Load mesh and run UV unwrap. Slow (xatlas). Only needs to run once per mesh."""
        logger.info("loading_mesh", path=str(self.config.mesh_path))
        vertices, faces = load_mesh(self.config.mesh_path)
        self._session.vertices, self._session.faces = vertices, faces
        logger.debug("mesh_loaded", vertices=len(vertices), faces=len(faces))

        logger.debug("unwrapping_uv")
        (
            self._session.uvs,
            self._session.faces_unwrapped,
            self._session.vertices_unwrapped,
            self._session.param_corner,
            atlas_height,
            atlas_width,
        ) = unwrap_uv(vertices, faces)
        self._session.texture_height = atlas_height
        self._session.texture_width = atlas_width
        logger.debug("uv_unwrapped", atlas_height=atlas_height, atlas_width=atlas_width)

    @time_profiler("load_data")
    @mem_profiler("load_data")
    def _load_data(self) -> None:
        """Load scalar field from config.data_path. Fast; re-call when data changes."""
        logger.info("loading_data", path=str(self.config.data_path))
        self._session.scalar_field, self._session.xs, self._session.ys, self._session.zs = load_scalar_field(
            self.config.data_path
        )
        logger.debug("data_loaded", shape=self._session.scalar_field.shape)

    def _init_polyscope(self) -> None:
        """Initialize Polyscope and load assets. Call once per process."""
        logger.debug("initializing_polyscope")
        ps = self._ps
        ps.set_program_name("yumo2")
        ps.set_print_prefix("[Yumo2][Polyscope] ")
        ps.set_ground_plane_mode("shadow_only")
        ps.set_up_dir("z_up")
        ps.set_front_dir("x_front")
        if FONT_PATH.exists():

            def _prepare_fonts(font_atlas):
                font = font_atlas.AddFontFromFileTTF(str(FONT_PATH), 20.0)
                return font, font

            ps.set_prepare_imgui_fonts_callback(_prepare_fonts)
        else:
            logger.warning("cjk_font_not_found", path=str(FONT_PATH))
        ps.init()

        self._loaded_cmaps = load_colormaps(ps, ASSETS_ROOT / "colormaps")
        self._available_colormaps = [*self._loaded_cmaps.keys(), *BUILTIN_COLORMAPS]
        self._available_materials = self._load_materials(ASSETS_ROOT / "materials")
        logger.debug(
            "polyscope_assets_loaded",
            custom_colormaps=list(self._loaded_cmaps.keys()),
            materials=self._available_materials,
        )

    @time_profiler("setup_scene")
    @mem_profiler("setup_scene")
    def _setup_scene(self) -> None:
        """Register mesh, bake texture, and initialize camera. Re-callable per render."""
        logger.debug("setting_up_scene", profile=self.active_profile)
        self._normalize_default_settings()
        self._register_mesh()
        self._rebuild_texture()
        if self._session.saved_camera_view is not None:
            self._ps.set_camera_view_matrix(self._session.saved_camera_view.copy())
            logger.debug("camera_view_restored")
        else:
            # No saved view — set orbit center to mesh bbox, then capture as reset target.
            self._apply_scene_view_center()
            self._session.saved_camera_view = np.array(self._ps.get_camera_view_matrix(), dtype=float)
            logger.debug("camera_view_initialised_from_bbox")

    def _reset_session_context(self, settings: Settings, active_profile: str | None = None) -> None:
        """Reset all session-scoped state for a new session (new dataset + settings pair).
        Session context is transient — nothing here is persisted independently of Settings."""
        self.settings = settings
        if active_profile is not None:
            self.active_profile = active_profile
        self.scope.cleanup()
        self._session = SessionContext(
            saved_camera_view=(
                np.array(settings.camera_view, dtype=float) if settings.camera_view is not None else None
            ),
            new_profile_name=self._default_profile_name(),
            snapshot_filename=self.snapshot.default_filename(),
        )

    def _register_mesh(self) -> None:
        if self._ps is None:
            return

        self._mesh = self._ps.register_surface_mesh(
            "mesh", self._session.vertices_unwrapped, self._session.faces_unwrapped
        )
        self._mesh.set_color((0.75, 0.75, 0.75))
        self._mesh.set_material(self.settings.material)
        self._mesh.add_parameterization_quantity("uv", self._session.param_corner, defined_on="corners", enabled=True)

    def _scene_center(self) -> np.ndarray:
        if self._session.vertices is None or len(self._session.vertices) == 0:
            return np.zeros(3, dtype=float)
        bbox_min = np.min(self._session.vertices, axis=0)
        bbox_max = np.max(self._session.vertices, axis=0)
        return (bbox_min + bbox_max) / 2.0

    def _apply_scene_view_center(self) -> None:
        if self._ps is None:
            return
        self._ps.set_view_center_raw(self._scene_center())

    def _normalize_default_settings(self) -> None:
        if self.settings.colormap == DEFAULT_COLORMAP and self._loaded_cmaps:
            self.settings = self.settings.model_copy(update={"colormap": next(iter(self._loaded_cmaps))})

        if self.settings.material == DEFAULT_MATERIAL and DEFAULT_MATERIAL not in self._available_materials:
            fallback_material = self._available_materials[0] if self._available_materials else DEFAULT_MATERIAL
            self.settings = self.settings.model_copy(update={"material": fallback_material})

    def _load_materials(self, *directories: Path | None) -> list[str]:
        if self._ps is None:
            return list(BUILTIN_MATERIALS)

        discovered: list[str] = []
        for directory in directories:
            if directory is None or not directory.exists():
                continue
            for stem in sorted({path.stem.rsplit("_", 1)[0] for path in directory.glob("*_?.hdr")}):
                if stem in _LOADED_MATERIALS:
                    logger.debug("material_already_loaded", material=stem, directory=str(directory))
                    discovered.append(stem)
                    continue
                try:
                    self._ps.load_blendable_material(
                        stem,
                        filename_base=str(directory / stem),
                        filename_ext=".hdr",
                    )
                    discovered.append(stem)
                    _LOADED_MATERIALS.add(stem)
                except Exception as exc:  # pragma: no cover - depends on Polyscope runtime
                    logger.warning("failed_to_load_material", material=stem, directory=str(directory), error=str(exc))

        return [*discovered, *BUILTIN_MATERIALS]

    def _reset_camera_view(self) -> None:
        if self._ps is None or self._session.saved_camera_view is None:
            return
        self._ps.set_camera_view_matrix(self._session.saved_camera_view.copy())

    def _save_active_profile(self) -> None:
        if self._ps is not None:
            current_camera = np.array(self._ps.get_camera_view_matrix(), dtype=float)
            self._session.saved_camera_view = current_camera.copy()
            self.settings = self.settings.model_copy(update={"camera_view": current_camera.tolist()})
        save_profile(self.active_profile, self.settings, self.root_dir)
        self._session.setting_unsaved = False
        self._session.profile_status_msg = f"Saved profile '{self.active_profile}'"

    def _save_as_new_profile(self) -> None:
        profile_name = self._session.new_profile_name.strip()
        if not profile_name:
            self._session.profile_status_msg = "Profile name is required"
            return

        self.active_profile = profile_name
        self._save_active_profile()
        self._session.save_as_new_enabled = False
        self._session.new_profile_name = self._default_profile_name()

    def _current_range(self) -> tuple[float, float]:
        rows = self._session.texel_rows
        cols = self._session.texel_cols
        original_texture = self._session.original_texture
        if original_texture is not None and rows is not None and cols is not None and len(rows) > 0:
            transformed_samples = transform_scalar_data(original_texture[rows, cols], self.settings.scalar_transform)
            return finite_minmax(transformed_samples)

        scalar_field = self._session.scalar_field
        if scalar_field is None:
            raise RuntimeError("Scalar field not loaded")
        transformed_field = transform_scalar_data(scalar_field, self.settings.scalar_transform)
        return finite_minmax(transformed_field)

    def _effective_vminmax(self) -> tuple[float, float]:
        if self._session.effective_range is None:
            self._session.effective_range = self._current_range()

        color_min = self.settings.color_min if self.settings.color_min is not None else self._session.effective_range[0]
        color_max = self.settings.color_max if self.settings.color_max is not None else self._session.effective_range[1]
        return color_min, color_max

    @time_profiler("rebuild_texture")
    @mem_profiler("rebuild_texture")
    def _rebuild_texture(self) -> None:
        scalar_field = self._session.scalar_field
        uvs = self._session.uvs
        faces_unwrapped = self._session.faces_unwrapped
        vertices_unwrapped = self._session.vertices_unwrapped
        xs = self._session.xs
        ys = self._session.ys
        zs = self._session.zs
        texture_height = self._session.texture_height
        texture_width = self._session.texture_width
        if scalar_field is None:
            raise RuntimeError("Texture rebuild requires loaded scalar field")
        if uvs is None or faces_unwrapped is None or vertices_unwrapped is None:
            raise RuntimeError("Texture rebuild requires loaded mesh UV state")
        if xs is None or ys is None or zs is None:
            raise RuntimeError("Texture rebuild requires loaded meshgrid axes")
        if texture_height is None or texture_width is None:
            raise RuntimeError("Texture rebuild requires texture dimensions")

        logger.debug(
            "rebuilding_texture",
            height=texture_height,
            width=texture_width,
            scalar_transform=self.settings.scalar_transform,
            denoise=self.settings.denoise_enabled,
            sigma=self.settings.denoise_sigma,
        )
        face_map, texel_rows, texel_cols, texel_positions = sample_texture_positions(
            vertices_unwrapped,
            faces_unwrapped,
            uvs,
            texture_height,
            texture_width,
        )
        self._session.uv_mask = (face_map >= 0).astype(np.float64)
        original_texture = np.zeros((texture_height, texture_width), dtype=np.float64)
        raw_texture = np.zeros((texture_height, texture_width), dtype=np.float64)
        transformed_samples = None
        if len(texel_rows) > 0:
            original_samples = trilinear_sample(texel_positions, scalar_field, xs, ys, zs)
            transformed_samples = transform_scalar_data(original_samples, self.settings.scalar_transform)
            original_texture[texel_rows, texel_cols] = original_samples
            raw_texture[texel_rows, texel_cols] = transformed_samples
        self._session.original_texture = original_texture
        self._session.raw_texture = raw_texture
        self._session.texel_rows = texel_rows
        self._session.texel_cols = texel_cols
        self._session.texel_positions = texel_positions

        if self.settings.denoise_enabled and self.settings.denoise_sigma > 0:
            display_texture = apply_denoise(raw_texture, self._session.uv_mask, self.settings.denoise_sigma)
        else:
            display_texture = raw_texture

        self._session.texture = pad_texture_edges(display_texture, self._session.uv_mask)

        self._session.effective_range = (
            finite_minmax(transformed_samples) if transformed_samples is not None else self._current_range()
        )
        self._refresh_quantities()

    def _refresh_quantities(self) -> None:
        if self._mesh is None or self._session.texture is None:
            return

        self._mesh.set_material(self.settings.material)
        self._mesh.remove_quantity("texture", error_if_absent=False)
        self._mesh.add_scalar_quantity(
            "texture",
            self._session.texture,
            defined_on="texture",
            param_name="uv",
            image_origin="upper_left",
            cmap=self.settings.colormap,
            vminmax=self._effective_vminmax(),
            enabled=True,
        )

    def _set_settings(self, **updates: object) -> None:
        self._log_settings_changed(updates)
        self.settings = self.settings.model_copy(update=updates)
        self._session.setting_unsaved = True

    def _switch_profile(self, next_profile: str) -> None:
        logger.info("switching_profile", profile=next_profile)
        set_active_profile(next_profile, self.root_dir)
        old = self._session
        self._reset_session_context(load_profile(next_profile, self.root_dir), active_profile=next_profile)
        # Preserve loaded data — only settings change when switching profiles.
        self._session.scalar_field = old.scalar_field
        self._session.xs = old.xs
        self._session.ys = old.ys
        self._session.zs = old.zs
        self._session.vertices = old.vertices
        self._session.faces = old.faces
        self._session.vertices_unwrapped = old.vertices_unwrapped
        self._session.faces_unwrapped = old.faces_unwrapped
        self._session.uvs = old.uvs
        self._session.param_corner = old.param_corner
        self._session.texture_height = old.texture_height
        self._session.texture_width = old.texture_width
        self._session.texel_rows = old.texel_rows
        self._session.texel_cols = old.texel_cols
        self._session.texel_positions = old.texel_positions
        self._rebuild_texture()
        if self._session.saved_camera_view is not None:
            self._ps.set_camera_view_matrix(self._session.saved_camera_view.copy())
        elif self._ps is not None:
            self._session.saved_camera_view = np.array(self._ps.get_camera_view_matrix(), dtype=float)

    def _ui_profile_actions(self) -> None:  # pragma: no cover - Polyscope callback
        if self._psim.Button("Reset View"):
            self._reset_camera_view()
        self._psim.SameLine()
        if self._psim.Button("Save"):
            if self._session.save_as_new_enabled:
                self._save_as_new_profile()
            else:
                self._save_active_profile()

    def _ui_profile_save_as_new(self) -> None:  # pragma: no cover - Polyscope callback
        self._psim.SameLine()
        changed, save_as_new_enabled = ui_labeled_checkbox(
            self._psim,
            "As New",
            "##save_as_new",
            self._session.save_as_new_enabled,
        )
        if changed:
            self._session.save_as_new_enabled = save_as_new_enabled
            logger.debug("save_as_new_toggled", enabled=save_as_new_enabled)
            if self._session.save_as_new_enabled and not self._session.new_profile_name.strip():
                self._session.new_profile_name = self._default_profile_name()

        if self._session.save_as_new_enabled:
            name_width = ui_available_width(self._psim, min_width=140.0, max_width=260.0)
            with ui_labeled_item_width(self._psim, "Name", name_width):
                changed, new_profile_name = self._psim.InputText("##profile_name", self._session.new_profile_name)
            if changed:
                self._session.new_profile_name = new_profile_name
                logger.debug("new_profile_name_changed", profile_name=new_profile_name)

    def _ui_profile_status(self) -> None:  # pragma: no cover - Polyscope callback
        if self._session.profile_status_msg:
            self._psim.Text(self._session.profile_status_msg)
        if self._session.setting_unsaved:
            self._psim.Text("Unsaved changes")

    @freq_time_profiler("ui_info")
    def _ui_info_section(self) -> None:  # pragma: no cover - Polyscope callback
        with ui_tree_node(self._psim, "Info", open_first_time=True) as expanded:
            if expanded:
                self._psim.Text(f"Version: {__version__}")
                self._psim.Text(f"FPS:   {self.fps:.1f}")
                self._psim.Text(f"Data:  {self.config.data_path.name}")
                self._psim.Text(f"Mesh:  {self.config.mesh_path.name}")

    @freq_time_profiler("ui_profile")
    def _ui_profile_section(self) -> None:  # pragma: no cover - Polyscope callback
        with ui_tree_node(self._psim, "Profile", open_first_time=True) as expanded:
            if expanded:
                profile_width = ui_available_width(self._psim, min_width=160.0, max_width=280.0)
                with ui_labeled_item_width(self._psim, "Profile", profile_width):
                    profile_changed, next_profile = ui_select(
                        self._psim, "##profile", self.active_profile, list_profiles(self.root_dir)
                    )
                if profile_changed:
                    self._switch_profile(next_profile)

                self._ui_profile_actions()
                self._ui_profile_save_as_new()
                self._ui_profile_status()

    @freq_time_profiler("ui_appearance")
    def _ui_appearance_section(self) -> None:  # pragma: no cover - Polyscope callback
        with ui_tree_node(self._psim, "Appearance", open_first_time=True) as expanded:
            if expanded:
                colormap_width, material_width = ui_equal_widths(self._psim, 2, min_width=90.0, max_width=150.0)
                with ui_labeled_item_width(self._psim, "Colormap", colormap_width):
                    colormap_changed, colormap = ui_select(
                        self._psim, "##colormap", self.settings.colormap, self._available_colormaps
                    )
                if colormap_changed:
                    self._set_settings(colormap=colormap)
                    self._refresh_quantities()

                self._psim.SameLine()
                with ui_labeled_item_width(self._psim, "Material", material_width):
                    material_changed, material = ui_select(
                        self._psim, "##material", self.settings.material, self._available_materials
                    )
                if material_changed:
                    self._set_settings(material=material)
                    self._refresh_quantities()

                effective_min, effective_max = self._effective_vminmax()
                color_min_width, color_max_width = ui_equal_widths(
                    self._psim,
                    2,
                    reserve=260.0,
                    min_width=70.0,
                    max_width=140.0,
                )
                with ui_labeled_item_width(self._psim, "Color Min", color_min_width):
                    min_changed, color_min = self._psim.InputFloat("##color_min", effective_min)
                self._psim.SameLine()
                with ui_labeled_item_width(self._psim, "Color Max", color_max_width):
                    max_changed, color_max = self._psim.InputFloat("##color_max", effective_max)
                self._psim.SameLine()
                if self._psim.Button("Auto Adjust"):
                    color_min, color_max = self._current_range()
                    min_changed = max_changed = True
                if min_changed or max_changed:
                    self._set_settings(color_min=color_min, color_max=color_max)
                    self._refresh_quantities()

    @freq_time_profiler("ui_processing")
    def _ui_processing_section(self) -> None:  # pragma: no cover - Polyscope callback
        with ui_tree_node(self._psim, "Processing", open_first_time=True) as expanded:
            if expanded:
                transform_width = ui_available_width(self._psim, min_width=140.0, max_width=260.0)
                with ui_labeled_item_width(self._psim, "Transform", transform_width):
                    transform_changed, scalar_transform = ui_select(
                        self._psim,
                        "##scalar_transform",
                        self.settings.scalar_transform,
                        ["identity", "log_e", "log_10"],
                    )
                if transform_changed:
                    self._set_settings(scalar_transform=scalar_transform, color_min=None, color_max=None)
                    self._rebuild_texture()

                denoise_changed, denoise_enabled = ui_labeled_checkbox(
                    self._psim,
                    "Gaussian Denoise",
                    "##gaussian_denoise",
                    self.settings.denoise_enabled,
                )
                if denoise_changed:
                    self._set_settings(denoise_enabled=denoise_enabled)
                    self._rebuild_texture()

                self._psim.SameLine()
                sigma_width = ui_available_width(self._psim, min_width=90.0, max_width=140.0)
                with ui_labeled_item_width(self._psim, "Gaussian Sigma", sigma_width):
                    sigma_changed, denoise_sigma = self._psim.InputFloat(
                        "##gaussian_sigma", self.settings.denoise_sigma
                    )
                if sigma_changed:
                    self._set_settings(denoise_sigma=max(0.0, denoise_sigma))
                    self._rebuild_texture()

post_load_hook()

Public extension point after mesh/data load, before Polyscope init.

Source code in yumo2/app.py
def post_load_hook(self) -> None:
    """Public extension point after mesh/data load, before Polyscope init."""

post_polyscope_init_hook()

Public extension point after Polyscope init, before scene setup.

Source code in yumo2/app.py
def post_polyscope_init_hook(self) -> None:
    """Public extension point after Polyscope init, before scene setup."""

post_scene_setup_hook()

Public extension point after scene setup, before entering the UI loop.

Source code in yumo2/app.py
def post_scene_setup_hook(self) -> None:
    """Public extension point after scene setup, before entering the UI loop."""

pre_load_hook()

Public extension point before any data or Polyscope state is loaded.

Source code in yumo2/app.py
def pre_load_hook(self) -> None:
    """Public extension point before any data or Polyscope state is loaded."""

run()

Launch the GUI and drive the full application lifecycle.

Source code in yumo2/app.py
def run(self) -> None:
    """Launch the GUI and drive the full application lifecycle."""
    import polyscope as ps
    import polyscope.imgui as psim

    self._ensure_default_imgui_ini()
    self._ps = ps
    self._psim = psim

    self.pre_load_hook()
    self._load_mesh_and_uv()
    self._load_data()
    self.post_load_hook()

    self._init_polyscope()
    self.post_polyscope_init_hook()

    self._setup_scene()
    self.post_scene_setup_hook()

    ps.set_user_callback(self.callback)

    try:
        ps.show()
    finally:
        self.scope.cleanup()
        self.snapshot.cleanup()

SessionContext dataclass

Transient state for one session (one loaded dataset + settings pair). Everything here is ephemeral — nothing is persisted outside of Settings.

Populated in stages

_reset_session_context() → settings-derived fields (saved_camera_view, etc.) _load_data() → scalar_field, xs, ys, zs _load_mesh_and_uv() → vertices, faces, UV fields, texture dimensions _rebuild_texture() → uv_mask, original_texture, raw_texture, texture, effective_range

Source code in yumo2/app.py
@dataclass
class SessionContext:
    """Transient state for one session (one loaded dataset + settings pair).
    Everything here is ephemeral — nothing is persisted outside of Settings.

    Populated in stages:
      _reset_session_context() → settings-derived fields (saved_camera_view, etc.)
      _load_data()             → scalar_field, xs, ys, zs
      _load_mesh_and_uv()      → vertices, faces, UV fields, texture dimensions
      _rebuild_texture()       → uv_mask, original_texture, raw_texture, texture, effective_range
    """

    # --- scalar field ---
    scalar_field: np.ndarray | None = None
    xs: np.ndarray | None = None
    ys: np.ndarray | None = None
    zs: np.ndarray | None = None

    # --- mesh + UV ---
    vertices: np.ndarray | None = None
    faces: np.ndarray | None = None
    vertices_unwrapped: np.ndarray | None = None
    faces_unwrapped: np.ndarray | None = None
    uvs: np.ndarray | None = None
    param_corner: np.ndarray | None = None
    texture_height: int | None = None
    texture_width: int | None = None

    # --- baked texture ---
    uv_mask: np.ndarray | None = None
    original_texture: np.ndarray | None = None
    raw_texture: np.ndarray | None = None
    texture: np.ndarray | None = None
    texel_rows: np.ndarray | None = None
    texel_cols: np.ndarray | None = None
    texel_positions: np.ndarray | None = None

    # --- settings-derived / UI state ---
    saved_camera_view: np.ndarray | None = None
    effective_range: tuple[float, float] | None = None
    setting_unsaved: bool = False
    save_as_new_enabled: bool = False
    new_profile_name: str = ""
    profile_status_msg: str = ""
    picker_msgs: list[str] = field(default_factory=list)
    scope_msgs: list[str] = field(default_factory=list)
    scope_press_world: np.ndarray | None = None
    scope_cross_center: np.ndarray | None = None
    snapshot_status_msg: str = ""
    snapshot_filename: str = ""
    snapshot_textures_initialized: bool = False
    snapshot_last_preview_time: float | None = None

Settings

Bases: BaseModel

Immutable user-facing settings persisted as a named profile.

All UI-controllable parameters live here. Instances are created by :func:load_active_profile / :func:load_profile and written to disk by :func:save_profile. PolyscopeApp._set_settings is the only code path that should mutate the active settings — it creates a new instance and saves it atomically.

Source code in yumo2/settings.py
class Settings(BaseModel):
    """Immutable user-facing settings persisted as a named profile.

    All UI-controllable parameters live here.  Instances are created by
    :func:`load_active_profile` / :func:`load_profile` and written to disk by
    :func:`save_profile`.  ``PolyscopeApp._set_settings`` is the only code path
    that should mutate the active settings — it creates a new instance and saves
    it atomically.
    """

    model_config = ConfigDict(frozen=True)
    colormap: str = DEFAULT_COLORMAP
    color_min: float | None = None
    color_max: float | None = None
    scalar_transform: Literal["identity", "log_e", "log_10"] = Field(
        default="log_10",
        validation_alias=AliasChoices("scalar_transform", "preprocess_method"),
    )
    material: str = DEFAULT_MATERIAL
    denoise_enabled: bool = True
    denoise_sigma: float = 3.0
    camera_view: list[list[float]] | None = None
    snapshot_crop_h: int = 600
    snapshot_crop_w: int = 800
    snapshot_crop_x: int = 0
    snapshot_crop_y: int = 0
    snapshot_colorbar_h: int = 300
    snapshot_colorbar_w: int = 120
    snapshot_colorbar_x: int = 10
    snapshot_colorbar_y: int = 10
    snapshot_colorbar_font_size: int = 12
    snapshot_live_preview: bool = False
    scope_enabled: bool = True
    scope_mode: Literal["Min", "Max"] = "Max"
    scope_radius: float = 0.5
    scope_marker_length_px: float = 30.0
    scope_marker_thickness_px: float = 3.0
    scope_marker_max_fraction: float = 0.02

load_mesh(path)

Load a triangular mesh from path using trimesh.

Parameters:

Name Type Description Default
path Path

Path to a mesh file (.stl, .obj, .ply, …).

required

Returns:

Type Description
tuple[ndarray, ndarray]

(vertices, faces) as float64 and int arrays respectively.

Raises:

Type Description
TypeError

If the file does not yield a triangular mesh.

Source code in yumo2/loader.py
def load_mesh(path: Path) -> tuple[np.ndarray, np.ndarray]:
    """Load a triangular mesh from *path* using trimesh.

    Args:
        path: Path to a mesh file (``.stl``, ``.obj``, ``.ply``, …).

    Returns:
        ``(vertices, faces)`` as float64 and int arrays respectively.

    Raises:
        TypeError: If the file does not yield a triangular mesh.
    """
    mesh = trimesh.load_mesh(path)
    if not isinstance(mesh, trimesh.Trimesh):
        raise TypeError(f"Could not load a triangular mesh from {path}")
    return np.asarray(mesh.vertices), np.asarray(mesh.faces)

load_scalar_field(path)

Load a scalar field from a plain-text or Tecplot .plt file.

Each data line must contain exactly four whitespace-separated floats x y z value. Header/comment lines are skipped automatically.

Parameters:

Name Type Description Default
path Path

Path to the data file.

required

Returns:

Type Description
tuple[ndarray, ndarray, ndarray, ndarray]

(scalar_field, xs, ys, zs) — see :func:validate_meshgrid.

Raises:

Type Description
ValueError

If no valid numeric rows are found or the data is not a regular grid.

Source code in yumo2/loader.py
def load_scalar_field(path: Path) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Load a scalar field from a plain-text or Tecplot ``.plt`` file.

    Each data line must contain exactly four whitespace-separated floats
    ``x y z value``.  Header/comment lines are skipped automatically.

    Args:
        path: Path to the data file.

    Returns:
        ``(scalar_field, xs, ys, zs)`` — see :func:`validate_meshgrid`.

    Raises:
        ValueError: If no valid numeric rows are found or the data is not a
            regular grid.
    """
    gen = _numeric_lines(path)
    first = next(gen, None)
    if first is None:
        raise ValueError(f"No numeric scalar field rows found in {path}")

    points = np.loadtxt(itertools.chain([first], gen), dtype=np.float64)
    points = np.atleast_2d(points)

    return validate_meshgrid(points)

validate_meshgrid(points)

Validate that points form a regular 3-D grid and build the scalar field array.

Parameters:

Name Type Description Default
points ndarray

Shape (N, 4) array of [x, y, z, value] rows.

required

Returns:

Type Description
ndarray

(scalar_field, xs, ys, zs) where scalar_field has shape

ndarray

(len(xs), len(ys), len(zs)) and NaN for any missing grid cell.

Raises:

Type Description
ValueError

If the data is not shaped (N, 4), axes are non-uniform, the point count exceeds the grid, or duplicate grid cells are detected.

Source code in yumo2/loader.py
def validate_meshgrid(points: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Validate that *points* form a regular 3-D grid and build the scalar field array.

    Args:
        points: Shape ``(N, 4)`` array of ``[x, y, z, value]`` rows.

    Returns:
        ``(scalar_field, xs, ys, zs)`` where *scalar_field* has shape
        ``(len(xs), len(ys), len(zs))`` and NaN for any missing grid cell.

    Raises:
        ValueError: If the data is not shaped ``(N, 4)``, axes are non-uniform,
            the point count exceeds the grid, or duplicate grid cells are detected.
    """
    if points.ndim != 2 or points.shape[1] != 4:
        raise ValueError(f"Expected points with shape (N, 4), got {points.shape}")

    xs = np.unique(points[:, 0])
    ys = np.unique(points[:, 1])
    zs = np.unique(points[:, 2])

    _validate_uniform_spacing(xs, "x")
    _validate_uniform_spacing(ys, "y")
    _validate_uniform_spacing(zs, "z")

    xi = np.searchsorted(xs, points[:, 0])
    yi = np.searchsorted(ys, points[:, 1])
    zi = np.searchsorted(zs, points[:, 2])

    scalar_field = np.full((len(xs), len(ys), len(zs)), np.nan, dtype=np.float64)

    if len(points) > scalar_field.size:
        raise ValueError("Point count exceeds meshgrid size")

    # Detect duplicate grid cells: same (xi, yi, zi) appearing more than once
    flat_indices = xi * (len(ys) * len(zs)) + yi * len(zs) + zi
    if len(flat_indices) != len(np.unique(flat_indices)):
        raise ValueError("Duplicate grid cells detected in input data")

    scalar_field[xi, yi, zi] = points[:, 3]

    missing_cells = int(np.isnan(scalar_field).sum())
    if missing_cells:
        logger.warning("input_scalar_field_has_missing_grid_cells", missing_cells=missing_cells)

    return scalar_field, xs, ys, zs

apply_denoise(texture, uv_mask, sigma)

Apply masked Gaussian blur to texture, preserving UV island boundaries.

Blending is weighted by uv_mask so that smoothing never bleeds across UV seams into uncovered texels.

Parameters:

Name Type Description Default
texture ndarray

(H, W) raw baked texture (float64).

required
uv_mask ndarray

(H, W) float mask; 1.0 inside covered texels, 0.0 outside.

required
sigma float

Gaussian standard deviation in texels. sigma <= 0 returns an unmodified copy.

required

Returns:

Type Description
ndarray

Smoothed (H, W) texture, zeroed outside the UV mask.

Source code in yumo2/texture.py
def apply_denoise(texture: np.ndarray, uv_mask: np.ndarray, sigma: float) -> np.ndarray:
    """Apply masked Gaussian blur to *texture*, preserving UV island boundaries.

    Blending is weighted by *uv_mask* so that smoothing never bleeds across
    UV seams into uncovered texels.

    Args:
        texture: ``(H, W)`` raw baked texture (float64).
        uv_mask: ``(H, W)`` float mask; 1.0 inside covered texels, 0.0 outside.
        sigma: Gaussian standard deviation in texels.  ``sigma <= 0`` returns
            an unmodified copy.

    Returns:
        Smoothed ``(H, W)`` texture, zeroed outside the UV mask.
    """
    if sigma <= 0:
        return texture.copy()

    weighted_texture = texture * uv_mask
    blurred_values = cv2.GaussianBlur(weighted_texture, ksize=(0, 0), sigmaX=sigma, sigmaY=sigma)
    blurred_weights = cv2.GaussianBlur(uv_mask, ksize=(0, 0), sigmaX=sigma, sigmaY=sigma)

    result = np.zeros_like(texture, dtype=np.float64)
    valid = blurred_weights > 1e-12
    result[valid] = blurred_values[valid] / blurred_weights[valid]
    result[uv_mask <= 0] = 0.0
    return result

bake_texture(vertices_unwrapped, faces_unwrapped, uvs, scalar_field, xs, ys, zs, height, width)

Bake one scalar sample per covered texel.

The texture resolution (height, width) defines the surface sampling grid in UV space: each covered texel center is mapped back to 3D and sampled once.

Source code in yumo2/texture.py
def bake_texture(
    vertices_unwrapped: np.ndarray,
    faces_unwrapped: np.ndarray,
    uvs: np.ndarray,
    scalar_field: np.ndarray,
    xs: np.ndarray,
    ys: np.ndarray,
    zs: np.ndarray,
    height: int,
    width: int,
) -> np.ndarray:
    """
    Bake one scalar sample per covered texel.

    The texture resolution `(height, width)` defines the surface sampling grid in
    UV space: each covered texel center is mapped back to 3D and sampled once.
    """
    face_map = build_face_map(uvs, faces_unwrapped, height, width)
    rows, cols = np.where(face_map >= 0)
    texture = np.zeros((height, width), dtype=np.float64)

    if len(rows) == 0:
        return texture

    face_indices = face_map[rows, cols]
    uv_triangles = uvs[faces_unwrapped[face_indices]]
    vertex_triangles = vertices_unwrapped[faces_unwrapped[face_indices]]

    sample_points = np.column_stack(((cols + 0.5) / width, 1.0 - (rows + 0.5) / height))
    barycentric = _barycentric_coordinates(sample_points, uv_triangles)
    positions = np.einsum("ij,ijk->ik", barycentric, vertex_triangles)
    values = trilinear_sample(positions, scalar_field, xs, ys, zs)

    texture[rows, cols] = values
    return texture

pad_texture_edges(texture, uv_mask, iterations=16)

Extend valid texel values into nearby uncovered padding texels.

This is a display-only fix for UV atlas filtering artifacts: Polyscope may sample just outside the covered UV region, so we copy nearby valid texel values into the atlas padding instead of leaving zeros there.

Source code in yumo2/texture.py
def pad_texture_edges(texture: np.ndarray, uv_mask: np.ndarray, iterations: int = 16) -> np.ndarray:
    """Extend valid texel values into nearby uncovered padding texels.

    This is a display-only fix for UV atlas filtering artifacts: Polyscope may
    sample just outside the covered UV region, so we copy nearby valid texel
    values into the atlas padding instead of leaving zeros there.
    """
    if iterations <= 0:
        return texture.copy()

    valid_mask = uv_mask > 0
    if not np.any(valid_mask):
        return texture.copy()

    valid_rows, valid_cols = np.where(valid_mask)
    labels = np.zeros(texture.shape, dtype=np.float32)
    labels[valid_rows, valid_cols] = np.arange(1, len(valid_rows) + 1, dtype=np.float32)
    frontier = labels.copy()
    kernel = np.ones((3, 3), dtype=np.uint8)

    for _ in range(iterations):
        grown = cv2.dilate(frontier, kernel, iterations=1)
        new_pixels = (~valid_mask) & (labels == 0) & (grown > 0)
        if not np.any(new_pixels):
            break
        labels[new_pixels] = grown[new_pixels]
        frontier = np.zeros_like(labels)
        frontier[new_pixels] = labels[new_pixels]

    padded = texture.copy()
    padded_mask = (~valid_mask) & (labels > 0)
    if np.any(padded_mask):
        source_indices = labels[padded_mask].astype(np.int64) - 1
        padded[padded_mask] = texture[valid_rows[source_indices], valid_cols[source_indices]]
    return padded

sample_texture_positions(vertices_unwrapped, faces_unwrapped, uvs, height, width)

Compute the 3-D world position for every covered texel center.

Used by :class:~yumo2.features.scope.Scope for spatial radius queries — each texel maps to one point on the mesh surface.

Parameters:

Name Type Description Default
vertices_unwrapped ndarray

(V', 3) vertex positions reindexed by xatlas.

required
faces_unwrapped ndarray

(F, 3) face indices into vertices_unwrapped.

required
uvs ndarray

(V', 2) UV coordinates.

required
height, width

Atlas texture dimensions.

required

Returns:

Type Description
ndarray

(face_map, rows, cols, positions)

ndarray
  • face_map: (H, W) int array mapping each texel to its face index (-1 for uncovered texels).
ndarray
  • rows, cols: 1-D index arrays of covered texels.
ndarray
  • positions: (N, 3) world-space position of each covered texel.
Source code in yumo2/texture.py
def sample_texture_positions(
    vertices_unwrapped: np.ndarray,
    faces_unwrapped: np.ndarray,
    uvs: np.ndarray,
    height: int,
    width: int,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Compute the 3-D world position for every covered texel center.

    Used by :class:`~yumo2.features.scope.Scope` for spatial radius queries —
    each texel maps to one point on the mesh surface.

    Args:
        vertices_unwrapped: ``(V', 3)`` vertex positions reindexed by xatlas.
        faces_unwrapped: ``(F, 3)`` face indices into *vertices_unwrapped*.
        uvs: ``(V', 2)`` UV coordinates.
        height, width: Atlas texture dimensions.

    Returns:
        ``(face_map, rows, cols, positions)``

        - *face_map*: ``(H, W)`` int array mapping each texel to its face index
          (``-1`` for uncovered texels).
        - *rows*, *cols*: 1-D index arrays of covered texels.
        - *positions*: ``(N, 3)`` world-space position of each covered texel.
    """
    face_map = build_face_map(uvs, faces_unwrapped, height, width)
    rows, cols = np.where(face_map >= 0)

    if len(rows) == 0:
        return face_map, rows, cols, np.zeros((0, 3), dtype=np.float64)

    face_indices = face_map[rows, cols]
    uv_triangles = uvs[faces_unwrapped[face_indices]]
    vertex_triangles = vertices_unwrapped[faces_unwrapped[face_indices]]
    sample_points = np.column_stack(((cols + 0.5) / width, 1.0 - (rows + 0.5) / height))
    barycentric = _barycentric_coordinates(sample_points, uv_triangles)
    valid = np.all(np.isfinite(barycentric), axis=1)
    if not np.all(valid):
        logger.warning(
            "degenerate_uv_triangles_skipped",
            skipped_texels=int((~valid).sum()),
            total_texels=len(valid),
        )
        rows = rows[valid]
        cols = cols[valid]
        vertex_triangles = vertex_triangles[valid]
        barycentric = barycentric[valid]
    positions = np.einsum("ij,ijk->ik", barycentric, vertex_triangles)
    return face_map, rows, cols, positions

trilinear_sample(positions, scalar_field, xs, ys, zs)

Trilinearly interpolate scalar_field at arbitrary 3-D positions.

Positions outside the grid are clamped to the grid boundary.

Parameters:

Name Type Description Default
positions ndarray

(N, 3) query coordinates.

required
scalar_field ndarray

(Nx, Ny, Nz) volumetric data array.

required
xs, ys, zs

1-D axis coordinate arrays defining the grid.

required

Returns:

Type Description
ndarray

(N,) interpolated values.

Source code in yumo2/texture.py
def trilinear_sample(
    positions: np.ndarray,
    scalar_field: np.ndarray,
    xs: np.ndarray,
    ys: np.ndarray,
    zs: np.ndarray,
) -> np.ndarray:
    """Trilinearly interpolate *scalar_field* at arbitrary 3-D *positions*.

    Positions outside the grid are clamped to the grid boundary.

    Args:
        positions: ``(N, 3)`` query coordinates.
        scalar_field: ``(Nx, Ny, Nz)`` volumetric data array.
        xs, ys, zs: 1-D axis coordinate arrays defining the grid.

    Returns:
        ``(N,)`` interpolated values.
    """
    x0, x1, tx = _axis_lerp(xs, positions[:, 0])
    y0, y1, ty = _axis_lerp(ys, positions[:, 1])
    z0, z1, tz = _axis_lerp(zs, positions[:, 2])

    c000 = scalar_field[x0, y0, z0]
    c100 = scalar_field[x1, y0, z0]
    c010 = scalar_field[x0, y1, z0]
    c110 = scalar_field[x1, y1, z0]
    c001 = scalar_field[x0, y0, z1]
    c101 = scalar_field[x1, y0, z1]
    c011 = scalar_field[x0, y1, z1]
    c111 = scalar_field[x1, y1, z1]

    c00 = c000 * (1 - tx) + c100 * tx
    c01 = c001 * (1 - tx) + c101 * tx
    c10 = c010 * (1 - tx) + c110 * tx
    c11 = c011 * (1 - tx) + c111 * tx

    c0 = c00 * (1 - ty) + c10 * ty
    c1 = c01 * (1 - ty) + c11 * ty

    return c0 * (1 - tz) + c1 * tz

unwrap_uv(vertices, faces, padding=16)

UV-unwrap a triangular mesh using xatlas.

Parameters:

Name Type Description Default
vertices ndarray

(V, 3) vertex positions.

required
faces ndarray

(F, 3) face indices.

required
padding int

Per-chart padding in texels (prevents UV bleeding between islands).

16

Returns:

Type Description
ndarray

(uvs, faces_unwrapped, vertices_unwrapped, param_corner, atlas_height, atlas_width)

ndarray
  • uvs: (V', 2) UV coordinates in [0, 1]².
ndarray
  • faces_unwrapped: (F, 3) face indices into uvs / vertices_unwrapped.
ndarray
  • vertices_unwrapped: (V', 3) vertices reindexed to match the UV atlas.
int
  • param_corner: (F*3, 2) per-corner UVs (convenience reshape).
int
  • atlas_height, atlas_width: atlas texture dimensions in texels.
Source code in yumo2/texture.py
def unwrap_uv(
    vertices: np.ndarray,
    faces: np.ndarray,
    padding: int = 16,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, int, int]:
    """UV-unwrap a triangular mesh using xatlas.

    Args:
        vertices: ``(V, 3)`` vertex positions.
        faces: ``(F, 3)`` face indices.
        padding: Per-chart padding in texels (prevents UV bleeding between islands).

    Returns:
        ``(uvs, faces_unwrapped, vertices_unwrapped, param_corner, atlas_height, atlas_width)``

        - *uvs*: ``(V', 2)`` UV coordinates in ``[0, 1]²``.
        - *faces_unwrapped*: ``(F, 3)`` face indices into *uvs* / *vertices_unwrapped*.
        - *vertices_unwrapped*: ``(V', 3)`` vertices reindexed to match the UV atlas.
        - *param_corner*: ``(F*3, 2)`` per-corner UVs (convenience reshape).
        - *atlas_height*, *atlas_width*: atlas texture dimensions in texels.
    """
    atlas = xatlas.Atlas()
    atlas.add_mesh(vertices, faces)

    pack_options = xatlas.PackOptions()
    pack_options.padding = padding
    pack_options.bilinear = True
    pack_options.rotate_charts = True

    atlas.generate(pack_options=pack_options)

    vmapping, faces_unwrapped, uvs = atlas[0]
    vertices_unwrapped = vertices[vmapping]
    param_corner = uvs[faces_unwrapped].reshape(-1, 2)

    return (
        uvs,
        faces_unwrapped,
        vertices_unwrapped,
        param_corner,
        atlas.height,
        atlas.width,
    )

freq_time_profiler

Decorator that accumulates timings over a rolling time window and logs stats periodically.

Only active when YUMO2_PROFILE=time|all. Designed for high-frequency functions (e.g. render callbacks) where per-call logging would be too noisy.

Source code in yumo2/profiling.py
class freq_time_profiler:
    """Decorator that accumulates timings over a rolling time window and logs stats periodically.

    Only active when YUMO2_PROFILE=time|all. Designed for high-frequency functions
    (e.g. render callbacks) where per-call logging would be too noisy.
    """

    def __init__(self, name: str | None = None, window_seconds: float = 5.0):
        self.name = name
        self.window_seconds = window_seconds
        self._samples: list[float] = []
        self._window_start: float | None = None

    def __call__(self, func):
        prof_name = self.name or func.__name__
        self.name = prof_name

        @wraps(func)
        def wrapper(*args, **kwargs):
            if not _profile_enabled("time"):
                return func(*args, **kwargs)
            t0 = time.perf_counter()
            try:
                return func(*args, **kwargs)
            finally:
                self._record(time.perf_counter() - t0)

        return wrapper

    def _record(self, elapsed: float) -> None:
        now = time.perf_counter()
        if self._window_start is None:
            self._window_start = now
        self._samples.append(elapsed)
        if now - self._window_start >= self.window_seconds:
            self._flush(now)

    def _flush(self, now: float) -> None:
        import statistics

        n = len(self._samples)
        mean = statistics.mean(self._samples)
        std = statistics.stdev(self._samples) if n >= 2 else 0.0
        logger.debug(
            "profile_freq",
            name=self.name,
            count=n,
            mean_seconds=round(mean, 6),
            std_seconds=round(std, 6),
            min_seconds=round(min(self._samples), 6),
            max_seconds=round(max(self._samples), 6),
            freq_hz=round(n / self.window_seconds, 2) if self.window_seconds > 0 else None,
        )
        self._samples = []
        self._window_start = now

mem_profiler

Bases: ContextDecorator

Memory profiler, usable as a context manager or decorator.

Only active when YUMO2_PROFILE=memory|all. Uses :mod:tracemalloc to capture peak RSS and the top-N allocation sites, logging them as mem_profile and mem_profile_top events respectively.

Example::

with mem_profiler("my_stage", top_n=5):
    big_array = np.zeros((10000, 10000))
Source code in yumo2/profiling.py
class mem_profiler(ContextDecorator):
    """Memory profiler, usable as a context manager or decorator.

    Only active when ``YUMO2_PROFILE=memory|all``.  Uses :mod:`tracemalloc` to
    capture peak RSS and the top-*N* allocation sites, logging them as
    ``mem_profile`` and ``mem_profile_top`` events respectively.

    Example::

        with mem_profiler("my_stage", top_n=5):
            big_array = np.zeros((10000, 10000))
    """

    def __init__(self, name=None, top_n: int = 10):
        self.name = name
        self.top_n = top_n
        self._enabled = False

    def __enter__(self):
        self._enabled = _profile_enabled("memory")
        if self._enabled:
            self._was_tracing = tracemalloc.is_tracing()
            if not self._was_tracing:
                tracemalloc.start()
            self._snapshot1 = tracemalloc.take_snapshot()
        return self

    def __exit__(self, *exc):
        if not self._enabled:
            return
        try:
            snapshot2 = tracemalloc.take_snapshot()
            current, peak = tracemalloc.get_traced_memory()
            logger.debug(
                "mem_profile",
                name=self.name,
                mem_current_mb=round(current / 1024 / 1024, 3),
                mem_peak_mb=round(peak / 1024 / 1024, 3),
            )
            stats = snapshot2.compare_to(self._snapshot1, "lineno")
            top = [s for s in stats if s.size_diff > 0][: self.top_n]
            for rank, stat in enumerate(top, start=1):
                frame = stat.traceback[0]
                logger.debug(
                    "mem_profile_top",
                    name=self.name,
                    rank=rank,
                    file=frame.filename,
                    lineno=frame.lineno,
                    size_diff_kb=round(stat.size_diff / 1024, 2),
                )
        finally:
            if not self._was_tracing:
                tracemalloc.stop()

    def __call__(self, func):
        prof_name = self.name or func.__name__

        @wraps(func)
        def wrapper(*args, **kwargs):
            with mem_profiler(prof_name, self.top_n):
                return func(*args, **kwargs)

        return wrapper

time_profiler

Bases: ContextDecorator

Wall-clock timer, usable as a context manager or decorator.

Only active when YUMO2_PROFILE=time|all. Logs a single profile_elapsed event on exit.

Example::

with time_profiler("my_stage"):
    ...

@time_profiler("my_func")
def my_func(): ...
Source code in yumo2/profiling.py
class time_profiler(ContextDecorator):
    """Wall-clock timer, usable as a context manager or decorator.

    Only active when ``YUMO2_PROFILE=time|all``.  Logs a single
    ``profile_elapsed`` event on exit.

    Example::

        with time_profiler("my_stage"):
            ...

        @time_profiler("my_func")
        def my_func(): ...
    """

    def __init__(self, name=None):
        self.name = name
        self._enabled = False

    def __enter__(self):
        self._enabled = _profile_enabled("time")
        if self._enabled:
            self._start = time.perf_counter()
        return self

    def __exit__(self, *exc):
        if self._enabled:
            elapsed = time.perf_counter() - self._start
            logger.debug("profile_elapsed", name=self.name, elapsed_seconds=elapsed)

    def __call__(self, func):
        prof_name = self.name or func.__name__

        @wraps(func)
        def wrapper(*args, **kwargs):
            with time_profiler(prof_name):
                return func(*args, **kwargs)

        return wrapper

Snapshot

Snapshot capture, preview, and export.

Renders the current Polyscope viewport to a cropped, colorbar-composited image. Maintains two ImGui image windows (overview with crop bounding-box, and a live preview of the final export) that are refreshed at up to _PREVIEW_MAX_HZ Hz.

All crop/colorbar geometry is stored in :class:~yumo2.settings.Settings so parameters survive profile switches.

Source code in yumo2/features/snapshot.py
class Snapshot:
    """Snapshot capture, preview, and export.

    Renders the current Polyscope viewport to a cropped, colorbar-composited
    image.  Maintains two ImGui image windows (overview with crop bounding-box,
    and a live preview of the final export) that are refreshed at up to
    ``_PREVIEW_MAX_HZ`` Hz.

    All crop/colorbar geometry is stored in :class:`~yumo2.settings.Settings`
    so parameters survive profile switches.
    """

    def __init__(self, app: PolyscopeApp) -> None:
        self.app = app
        self._overview_texture = NumpyImageTexture()
        self._preview_texture = NumpyImageTexture()

    def default_filename(self) -> str:
        ts = datetime.now().strftime("%Y%m%d-%H%M%S")
        return f"{ts}_{self.app.config.mesh_path.stem}_{self.app.config.data_path.stem}.png"

    def cleanup(self) -> None:
        self._overview_texture.release()
        self._preview_texture.release()

    def capture_rgba(self) -> np.ndarray:
        return self.app._ps.screenshot_to_buffer(transparent_bg=True)

    def crop_box(self, screen_h: int, screen_w: int) -> tuple[int, int, int, int]:
        cx = screen_w // 2 + self.app.settings.snapshot_crop_x
        cy = screen_h // 2 + self.app.settings.snapshot_crop_y
        x1 = max(0, min(cx - self.app.settings.snapshot_crop_w // 2, screen_w))
        y1 = max(0, min(cy - self.app.settings.snapshot_crop_h // 2, screen_h))
        x2 = max(0, min(x1 + self.app.settings.snapshot_crop_w, screen_w))
        y2 = max(0, min(y1 + self.app.settings.snapshot_crop_h, screen_h))
        return y1, x1, y2, x2

    def make_colorbar(self) -> np.ndarray:
        color_min, color_max = self.app._effective_vminmax()
        orientation: Literal["vertical", "horizontal"] = (
            "vertical"
            if self.app.settings.snapshot_colorbar_h >= self.app.settings.snapshot_colorbar_w
            else "horizontal"
        )
        return generate_colorbar_rgba(
            self.app.settings.snapshot_colorbar_h,
            self.app.settings.snapshot_colorbar_w,
            self.app.settings.colormap,
            color_min,
            color_max,
            self.app.settings.scalar_transform,
            self.app._loaded_cmaps,
            font_size=self.app.settings.snapshot_colorbar_font_size,
            orientation=orientation,
        )

    def overview(self, rgba: np.ndarray) -> np.ndarray:
        sh, sw = rgba.shape[:2]
        y1, x1, y2, x2 = self.crop_box(sh, sw)
        rgb = _flatten_rgba_on_white(rgba)
        border = 8
        rgb[y1 : y1 + border, x1:x2] = 0
        rgb[max(0, y2 - border) : y2, x1:x2] = 0
        rgb[y1:y2, x1 : x1 + border] = 0
        rgb[y1:y2, max(0, x2 - border) : x2] = 0
        return rgb

    def composed_rgba(self, rgba: np.ndarray) -> np.ndarray:
        sh, sw = rgba.shape[:2]
        y1, x1, y2, x2 = self.crop_box(sh, sw)
        crop = rgba[y1:y2, x1:x2].astype(np.float32) / 255.0
        return _overlay_rgba(
            crop,
            self.make_colorbar(),
            self.app.settings.snapshot_colorbar_x,
            self.app.settings.snapshot_colorbar_y,
        )

    def preview_rgb(self, rgba: np.ndarray) -> np.ndarray:
        composed = self.composed_rgba(rgba)
        alpha = composed[:, :, 3:4]
        return composed[:, :, :3] * alpha + (1.0 - alpha)

    def save(self) -> None:  # pragma: no cover - Polyscope runtime
        import cv2

        rgba = self.capture_rgba()
        composed = self.composed_rgba(rgba)
        bgra = cv2.cvtColor((composed * 255).astype(np.uint8), cv2.COLOR_RGBA2BGRA)
        filename = self.app._session.snapshot_filename.strip() or self.default_filename()
        path = Path(filename)
        cv2.imwrite(str(path), bgra)
        logger.info("snapshot_saved", path=str(path))
        self.app._session.snapshot_status_msg = f"Saved: {path.name}"
        self.app._session.snapshot_filename = self.default_filename()

    @freq_time_profiler("snapshot_capture")
    def _capture_preview_rgba(self) -> np.ndarray:  # pragma: no cover - Polyscope callback
        return self.capture_rgba()

    @freq_time_profiler("snapshot_compose")
    def _compose_preview_images(
        self, rgba: np.ndarray
    ) -> tuple[np.ndarray, np.ndarray]:  # pragma: no cover - Polyscope callback
        return self.overview(rgba), self.preview_rgb(rgba)

    @freq_time_profiler("snapshot_upload")
    def _upload_preview_images(
        self, overview: np.ndarray, preview: np.ndarray
    ) -> None:  # pragma: no cover - Polyscope callback
        self._overview_texture.update(overview)
        self._preview_texture.update(preview)

    def update_textures(self, rgba: np.ndarray) -> None:  # pragma: no cover - Polyscope callback
        overview, preview = self._compose_preview_images(rgba)
        self._upload_preview_images(overview, preview)

    def refresh_preview(self, now: float | None = None) -> None:  # pragma: no cover - Polyscope callback
        self.update_textures(self._capture_preview_rgba())
        self.app._session.snapshot_textures_initialized = True
        self.app._session.snapshot_last_preview_time = time.monotonic() if now is None else now

    def _should_refresh_preview(self, now: float) -> bool:
        if not self.app._session.snapshot_textures_initialized:
            return True
        if not self.app.settings.snapshot_live_preview:
            return False
        last = self.app._session.snapshot_last_preview_time
        if last is None:
            return True
        return (now - last) >= (1.0 / _PREVIEW_MAX_HZ)

    def draw_windows(self) -> None:  # pragma: no cover - Polyscope callback
        ui_image_window(
            self.app._psim,
            "snapshot_overview",
            self._overview_texture.texture_ref(),
            self._overview_texture.size(),
        )
        ui_image_window(
            self.app._psim,
            "snapshot_preview",
            self._preview_texture.texture_ref(),
            self._preview_texture.size(),
        )

    def _ui_image_controls(self, width: float) -> None:  # pragma: no cover - Polyscope callback
        with ui_tree_node(self.app._psim, "Image", open_first_time=True) as image_expanded:
            if not image_expanded:
                return
            with ui_labeled_item_width(self.app._psim, "Crop H", width):
                changed, value = self.app._psim.InputInt("##croph", self.app.settings.snapshot_crop_h, 0)
            if changed:
                self.app._set_settings(snapshot_crop_h=max(1, value))
            self.app._psim.SameLine()
            with ui_labeled_item_width(self.app._psim, "Crop W", width):
                changed, value = self.app._psim.InputInt("##cropw", self.app.settings.snapshot_crop_w, 0)
            if changed:
                self.app._set_settings(snapshot_crop_w=max(1, value))

            with ui_labeled_item_width(self.app._psim, "Crop X", width):
                changed, value = self.app._psim.InputInt("##offx", self.app.settings.snapshot_crop_x, 0)
            if changed:
                self.app._set_settings(snapshot_crop_x=value)
            self.app._psim.SameLine()
            with ui_labeled_item_width(self.app._psim, "Crop Y", width):
                changed, value = self.app._psim.InputInt("##offy", self.app.settings.snapshot_crop_y, 0)
            if changed:
                self.app._set_settings(snapshot_crop_y=value)

    def _ui_colorbar_controls(self, width: float) -> None:  # pragma: no cover - Polyscope callback
        with ui_tree_node(self.app._psim, "Colorbar", open_first_time=True) as colorbar_expanded:
            if not colorbar_expanded:
                return
            with ui_labeled_item_width(self.app._psim, "Bar H", width):
                changed, value = self.app._psim.InputInt("##cbh", self.app.settings.snapshot_colorbar_h, 0)
            if changed:
                self.app._set_settings(snapshot_colorbar_h=max(1, value))
            self.app._psim.SameLine()
            with ui_labeled_item_width(self.app._psim, "Bar W", width):
                changed, value = self.app._psim.InputInt("##cbw", self.app.settings.snapshot_colorbar_w, 0)
            if changed:
                self.app._set_settings(snapshot_colorbar_w=max(1, value))

            with ui_labeled_item_width(self.app._psim, "Bar X", width):
                changed, value = self.app._psim.InputInt("##cbx", self.app.settings.snapshot_colorbar_x, 0)
            if changed:
                self.app._set_settings(snapshot_colorbar_x=value)
            self.app._psim.SameLine()
            with ui_labeled_item_width(self.app._psim, "Bar Y", width):
                changed, value = self.app._psim.InputInt("##cby", self.app.settings.snapshot_colorbar_y, 0)
            if changed:
                self.app._set_settings(snapshot_colorbar_y=value)

            with ui_labeled_item_width(self.app._psim, "Font", width):
                changed, value = self.app._psim.InputInt("##cbfont", self.app.settings.snapshot_colorbar_font_size, 0)
            if changed:
                self.app._set_settings(snapshot_colorbar_font_size=max(1, value))

    @freq_time_profiler("ui_snapshot")
    def ui(self) -> None:  # pragma: no cover - Polyscope callback
        now = time.monotonic()
        if self._should_refresh_preview(now):
            self.refresh_preview(now)
        self.draw_windows()

        with ui_tree_node(self.app._psim, "Snapshot", open_first_time=False) as expanded:
            if not expanded:
                return

            width = min(max(ui_available_width(self.app._psim) / 2.0, 90.0), 140.0)
            self._ui_image_controls(width)
            self._ui_colorbar_controls(width)
            filename_width = ui_available_width(self.app._psim, reserve=90.0, min_width=140.0, max_width=320.0)
            with ui_labeled_item_width(self.app._psim, "Filename", filename_width):
                changed, filename = self.app._psim.InputText("##snap", self.app._session.snapshot_filename)
            if changed:
                self.app._session.snapshot_filename = filename
                logger.debug("snapshot_filename_changed", filename=filename)

            changed, live = ui_labeled_checkbox(
                self.app._psim,
                "Live Preview",
                "##snapshot_live_preview",
                self.app.settings.snapshot_live_preview,
            )
            if changed:
                self.app._set_settings(snapshot_live_preview=live)

            self.app._psim.SameLine()
            if self.app._psim.Button("Refresh"):
                self.refresh_preview()

            self.app._psim.SameLine()

            if self.app._psim.Button("Save PNG"):
                self.save()
            if self.app._session.snapshot_status_msg:
                self.app._psim.Text(self.app._session.snapshot_status_msg)

Scope

Interactive spatial extremum finder.

When enabled, a mouse click registers a world-space center point. The sphere marker shows the query radius; on release, :meth:query_extremum searches all texel positions within the radius and returns the texel with the minimum or maximum raw scalar value. A screen-space-proportional cross marker is placed at the result and updated every frame as the camera moves.

Source code in yumo2/features/scope.py
class Scope:
    """Interactive spatial extremum finder.

    When enabled, a mouse click registers a world-space center point.  The
    sphere marker shows the query radius; on release, :meth:`query_extremum`
    searches all texel positions within the radius and returns the texel with
    the minimum or maximum raw scalar value.  A screen-space-proportional cross
    marker is placed at the result and updated every frame as the camera moves.
    """

    def __init__(self, app: PolyscopeApp) -> None:
        self.app = app
        self._sphere: Any = None
        self._cross: Any = None

    def cleanup(self) -> None:  # pragma: no cover - Polyscope callback
        self._hide_sphere()
        self._hide_cross()

    def query_extremum(
        self,
        center: np.ndarray,
        radius: float,
        mode: Literal["Min", "Max"],
    ) -> ScopeQueryResult | None:
        raw_texture = self.app._session.raw_texture
        original_texture = self.app._session.original_texture
        texture = self.app._session.texture
        rows = self.app._session.texel_rows
        cols = self.app._session.texel_cols
        positions = self.app._session.texel_positions
        if original_texture is None or raw_texture is None or texture is None:
            raise RuntimeError("Scope query requires baked textures")
        if rows is None or cols is None or positions is None:
            raise RuntimeError("Scope query requires sampled scope positions")

        distances = np.linalg.norm(positions - np.asarray(center, dtype=float), axis=1)
        inside = distances <= float(radius)
        if not np.any(inside):
            return None

        rows_in = rows[inside]
        cols_in = cols[inside]
        positions_in = positions[inside]
        raw_values = raw_texture[rows_in, cols_in]
        choice = int(np.argmax(raw_values) if mode == "Max" else np.argmin(raw_values))
        smoothed_value = None
        if self.app.settings.denoise_enabled and self.app.settings.denoise_sigma > 0:
            smoothed_value = float(texture[rows_in[choice], cols_in[choice]])

        return ScopeQueryResult(
            position=np.asarray(positions_in[choice], dtype=float),
            raw_value=float(raw_values[choice]),
            smoothed_value=smoothed_value,
            original_value=float(original_texture[rows_in[choice], cols_in[choice]]),
        )

    def _scene_scale(self) -> float:
        vertices = self.app._session.vertices
        if vertices is None or len(vertices) == 0:
            return 1.0
        return float(np.linalg.norm(np.ptp(vertices, axis=0)))

    def _show_sphere(self, center: np.ndarray) -> None:  # pragma: no cover - Polyscope callback
        point = np.asarray(center, dtype=float).reshape(1, 3)
        if self._sphere is None:
            self._sphere = self.app._ps.register_point_cloud(
                "scope_sphere",
                point,
                enabled=True,
                point_render_mode="sphere",
                color=(1.0, 1.0, 1.0),
                transparency=0.35,
            )
            self._sphere.set_radius(self.app.settings.scope_radius, relative=False)
        else:
            self._sphere.update_point_positions(point)
            self._sphere.set_radius(self.app.settings.scope_radius, relative=False)
            self._sphere.set_enabled(True)

    def _hide_sphere(self) -> None:  # pragma: no cover - Polyscope callback
        if self._sphere is not None:
            self._sphere.set_enabled(False)

    def _show_cross(self, center: np.ndarray) -> None:  # pragma: no cover - Polyscope callback
        self.app._session.scope_cross_center = np.asarray(center, dtype=float)
        self._update_cross_geometry()

    def _update_cross_geometry(self) -> None:  # pragma: no cover - Polyscope callback
        if self.app._session.scope_cross_center is None:
            return
        import math

        cam = self.app._ps.get_view_camera_parameters()
        dist = float(np.linalg.norm(self.app._session.scope_cross_center - np.asarray(cam.get_position())))
        if dist == 0:
            return
        world_per_pixel = (2.0 * dist * math.tan(math.radians(cam.get_fov_vertical_deg()) / 2.0)) / float(
            self.app._psim.GetIO().DisplaySize[1]
        )
        max_cross_length = self._scene_scale() * self.app.settings.scope_marker_max_fraction
        cross_length = min(self.app.settings.scope_marker_length_px * world_per_pixel, max_cross_length)
        line_radius = min(
            0.5 * self.app.settings.scope_marker_thickness_px * world_per_pixel,
            max_cross_length * _LINE_MAX_CROSS_FRACTION,
        )
        nodes, edges = cross_nodes_edges(self.app._session.scope_cross_center, cross_length)
        if self._cross is None:
            self._cross = self.app._ps.register_curve_network("scope_cross", nodes, edges, enabled=True)
            self._cross.set_color((0.0, 0.0, 0.0))
            self._cross.set_material("flat")
        else:
            self._cross.update_node_positions(nodes)
            self._cross.set_enabled(True)
        self._cross.set_radius(line_radius, relative=False)

    def _hide_cross(self) -> None:  # pragma: no cover - Polyscope callback
        if hasattr(self.app, "_session"):
            self.app._session.scope_cross_center = None
        if self._cross is not None:
            self._cross.set_enabled(False)

    def _handle_interaction(self) -> None:  # pragma: no cover - Polyscope callback
        io = self.app._psim.GetIO()
        if io.MouseClicked[0]:
            self.app._session.scope_press_world = np.asarray(
                self.app._ps.screen_coords_to_world_position(io.MousePos),
                dtype=float,
            )
            logger.debug(
                "scope_press_started",
                screen_coords=tuple(io.MousePos),
                center=tuple(float(value) for value in self.app._session.scope_press_world),
            )
            self._show_sphere(self.app._session.scope_press_world)
            self._hide_cross()

        if io.MouseDown[0] and self.app._session.scope_press_world is not None:
            self._show_sphere(self.app._session.scope_press_world)

        if io.MouseReleased[0] and self.app._session.scope_press_world is not None:
            center = self.app._session.scope_press_world.copy()
            self.app._session.scope_press_world = None
            self._hide_sphere()
            logger.debug(
                "scope_started",
                center=tuple(float(value) for value in center),
                radius=self.app.settings.scope_radius,
                mode=self.app.settings.scope_mode,
            )

            self.app._session.scope_msgs = [f"Scope center: {_format_position(center)}"]
            result = self.query_extremum(center, self.app.settings.scope_radius, self.app.settings.scope_mode)
            if result is None:
                self._hide_cross()
                self.app._session.scope_msgs.append("No sampled mesh points in scope")
                logger.debug("scope_no_samples", center=tuple(float(value) for value in center))
                return

            self._show_cross(result.position)
            self.app._session.scope_msgs.append(
                f"Scope {self.app.settings.scope_mode.lower()} position: {_format_position(result.position)}"
            )
            self.app._session.scope_msgs.append(f"Raw surface value: {result.raw_value:.6g}")
            if self.app.settings.scalar_transform in ("log_e", "log_10"):
                self.app._session.scope_msgs.append(
                    f"Original surface value: {_format_scientific(result.original_value)}"
                )
            if result.smoothed_value is not None:
                self.app._session.scope_msgs.append(f"Smoothed surface value: {result.smoothed_value:.6g}")
            logger.debug(
                "scope_completed",
                center=tuple(float(value) for value in center),
                position=tuple(float(value) for value in result.position),
                raw_value=result.raw_value,
                smoothed_value=result.smoothed_value,
                original_value=result.original_value,
            )

    def _ui_primary_controls(self) -> None:  # pragma: no cover - Polyscope callback
        changed, scope_enabled = ui_labeled_checkbox(
            self.app._psim,
            "Enabled",
            "##scope_enabled",
            self.app.settings.scope_enabled,
        )
        if changed:
            self.app._set_settings(scope_enabled=scope_enabled)
            if not scope_enabled:
                self.cleanup()

        self.app._psim.SameLine()
        mode_width, radius_width = ui_equal_widths(self.app._psim, 2, min_width=50.0, max_width=100.0)
        with ui_labeled_item_width(self.app._psim, "Mode", mode_width):
            mode_changed, scope_mode = ui_select(
                self.app._psim,
                "##scope_mode",
                self.app.settings.scope_mode,
                ["Min", "Max"],
            )
        if mode_changed:
            self.app._set_settings(scope_mode=scope_mode)

        self.app._psim.SameLine()
        with ui_labeled_item_width(self.app._psim, "Radius", radius_width):
            radius_changed, scope_radius = self.app._psim.InputFloat("##scope_radius", self.app.settings.scope_radius)
        if radius_changed:
            self.app._set_settings(scope_radius=max(0.0, scope_radius))

    def _ui_marker_controls(self) -> None:  # pragma: no cover - Polyscope callback
        marker_length_width, marker_thickness_width, marker_max_width = ui_equal_widths(
            self.app._psim, 3, reserve=330.0, min_width=60.0, max_width=120.0
        )
        with ui_labeled_item_width(self.app._psim, "Marker Length", marker_length_width):
            marker_length_changed, marker_length = self.app._psim.InputFloat(
                "##marker_length", self.app.settings.scope_marker_length_px
            )
        if marker_length_changed:
            self.app._set_settings(scope_marker_length_px=max(0.0, marker_length))

        self.app._psim.SameLine()
        with ui_labeled_item_width(self.app._psim, "Marker Thickness", marker_thickness_width):
            marker_thickness_changed, marker_thickness = self.app._psim.InputFloat(
                "##marker_thickness", self.app.settings.scope_marker_thickness_px
            )
        if marker_thickness_changed:
            self.app._set_settings(scope_marker_thickness_px=max(0.0, marker_thickness))

        with ui_labeled_item_width(self.app._psim, "Marker Max", marker_max_width):
            marker_max_changed, marker_max = self.app._psim.InputFloat(
                "##marker_max", self.app.settings.scope_marker_max_fraction
            )
        if marker_max_changed:
            self.app._set_settings(scope_marker_max_fraction=max(0.0, marker_max))

    def _ui_messages(self) -> None:  # pragma: no cover - Polyscope callback
        for message in self.app._session.scope_msgs:
            self.app._psim.Text(message)

        for _ in range(max(0, _PADDING - len(self.app._session.scope_msgs))):
            self.app._psim.Text("")

    @freq_time_profiler("ui_scope")
    def ui(self) -> None:  # pragma: no cover - Polyscope callback
        with ui_tree_node(self.app._psim, "Scope", open_first_time=False) as expanded:
            if not expanded:
                self.cleanup()
                return

            self._ui_primary_controls()
            self._ui_marker_controls()
            if self.app.settings.scope_enabled:
                self._handle_interaction()
                self._update_cross_geometry()

            self._ui_messages()

Picker

Point-and-click surface value query.

On each left-click inside the "Coord Picker" panel, reports the 3-D world coordinate of the click and, when the click lands on the mesh, uses barycentric interpolation in UV space to look up both the raw and denoised scalar values at that surface point.

Source code in yumo2/features/picker.py
class Picker:
    """Point-and-click surface value query.

    On each left-click inside the "Coord Picker" panel, reports the 3-D world
    coordinate of the click and, when the click lands on the mesh, uses
    barycentric interpolation in UV space to look up both the raw and
    denoised scalar values at that surface point.
    """

    def __init__(self, app: PolyscopeApp) -> None:
        self.app = app

    def mesh_pick_values(self, face_index: int, barycentric_coords: np.ndarray) -> tuple[float, float | None, float]:
        uvs = self.app._session.uvs
        faces_unwrapped = self.app._session.faces_unwrapped
        original_texture = self.app._session.original_texture
        raw_texture = self.app._session.raw_texture
        texture = self.app._session.texture
        if uvs is None or faces_unwrapped is None:
            raise RuntimeError("Mesh pick requires UV state")
        if original_texture is None or raw_texture is None or texture is None:
            raise RuntimeError("Mesh pick requires baked textures")

        uv_triangle = uvs[faces_unwrapped[face_index]]
        uv = barycentric_coords @ uv_triangle

        texture_height, texture_width = texture.shape[:2]
        u, v = uv
        col = int(np.clip(u * (texture_width - 1), 0, texture_width - 1))
        row = int(np.clip((1.0 - v) * (texture_height - 1), 0, texture_height - 1))

        original_value = float(original_texture[row, col])
        surface_value = float(raw_texture[row, col])
        smoothed_value = None
        if self.app.settings.denoise_enabled and self.app.settings.denoise_sigma > 0:
            smoothed_value = float(texture[row, col])

        return surface_value, smoothed_value, original_value

    @freq_time_profiler("ui_picker")
    def ui(self) -> None:  # pragma: no cover - Polyscope callback
        with ui_tree_node(self.app._psim, "Coord Picker", open_first_time=False) as expanded:
            if not expanded:
                return

            io = self.app._psim.GetIO()
            if io.MouseClicked[0]:
                self.app._session.picker_msgs = []

                screen_coords = io.MousePos
                world_coords = self.app._ps.screen_coords_to_world_position(screen_coords)
                self.app._session.picker_msgs.append(f"World coord: {_format_position(world_coords)}")
                logger.debug("picker_clicked", screen_coords=tuple(screen_coords), world_coords=tuple(world_coords))

                pick_result = self.app._ps.pick(screen_coords=screen_coords)
                if pick_result.is_hit and pick_result.structure_name == "mesh":
                    data = pick_result.structure_data
                    if "bary_coords" in data:
                        surface_value, smoothed_value, original_value = self.mesh_pick_values(
                            int(data["index"]),
                            np.asarray(data["bary_coords"], dtype=float),
                        )
                        self.app._session.picker_msgs.append(f"Surface value: {surface_value:.6g}")
                        if self.app.settings.scalar_transform in ("log_e", "log_10"):
                            self.app._session.picker_msgs.append(
                                f"Original surface value: {_format_scientific(original_value)}"
                            )
                        logger.debug(
                            "picker_sampled_values",
                            face_index=int(data["index"]),
                            surface_value=surface_value,
                            smoothed_value=smoothed_value,
                            original_value=original_value,
                        )

                        if smoothed_value is not None:
                            self.app._session.picker_msgs.append(f"Smoothed surface value: {smoothed_value:.6g}")
                            if surface_value != 0:
                                rel_error = abs(smoothed_value - surface_value) / abs(surface_value)
                                self.app._session.picker_msgs.append(f"Rel error: {rel_error:.6g}")
                                logger.debug("picker_relative_error", relative_error=rel_error)
                            else:
                                self.app._session.picker_msgs.append("Rel error: N/A (surface value is 0)")
                                logger.debug("picker_relative_error_unavailable", reason="surface_value_zero")

            for message in self.app._session.picker_msgs:
                self.app._psim.Text(message)

            for _ in range(max(0, _PADDING - len(self.app._session.picker_msgs))):
                self.app._psim.Text("")