arrayview 0.16.0__tar.gz → 0.17.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. {arrayview-0.16.0 → arrayview-0.17.0}/PKG-INFO +1 -1
  2. {arrayview-0.16.0 → arrayview-0.17.0}/pyproject.toml +1 -1
  3. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_server.py +560 -0
  4. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_stdio_server.py +1 -1
  5. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_viewer.html +2649 -321
  6. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_browser.py +13 -5
  7. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_interactions.py +14 -26
  8. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_mode_roundtrip.py +6 -3
  9. {arrayview-0.16.0 → arrayview-0.17.0}/tests/visual_smoke.py +19 -23
  10. {arrayview-0.16.0 → arrayview-0.17.0}/uv.lock +1 -1
  11. {arrayview-0.16.0 → arrayview-0.17.0}/.claude/skills/invocation-consistency/SKILL.md +0 -0
  12. {arrayview-0.16.0 → arrayview-0.17.0}/.claude/skills/modes-consistency/SKILL.md +0 -0
  13. {arrayview-0.16.0 → arrayview-0.17.0}/.claude/skills/ui-consistency-audit/SKILL.md +0 -0
  14. {arrayview-0.16.0 → arrayview-0.17.0}/.claude/skills/viewer-ui-checklist/SKILL.md +0 -0
  15. {arrayview-0.16.0 → arrayview-0.17.0}/.claude/skills/visual-bug-fixing/SKILL.md +0 -0
  16. {arrayview-0.16.0 → arrayview-0.17.0}/.github/copilot-instructions.md +0 -0
  17. {arrayview-0.16.0 → arrayview-0.17.0}/.github/workflows/docs.yml +0 -0
  18. {arrayview-0.16.0 → arrayview-0.17.0}/.github/workflows/python-publish.yml +0 -0
  19. {arrayview-0.16.0 → arrayview-0.17.0}/.gitignore +0 -0
  20. {arrayview-0.16.0 → arrayview-0.17.0}/.ignore +0 -0
  21. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/AGENTS.md +0 -0
  22. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/ROUTER.md +0 -0
  23. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/SETUP.md +0 -0
  24. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/SYNC.md +0 -0
  25. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/architecture.md +0 -0
  26. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/conventions.md +0 -0
  27. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/decisions.md +0 -0
  28. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/frontend.md +0 -0
  29. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/project-state.md +0 -0
  30. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/render-pipeline.md +0 -0
  31. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/setup.md +0 -0
  32. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/stack.md +0 -0
  33. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/patterns/INDEX.md +0 -0
  34. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/patterns/README.md +0 -0
  35. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/patterns/add-file-format.md +0 -0
  36. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/patterns/add-server-endpoint.md +0 -0
  37. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/patterns/debug-render.md +0 -0
  38. {arrayview-0.16.0 → arrayview-0.17.0}/.mex/patterns/frontend-change.md +0 -0
  39. {arrayview-0.16.0 → arrayview-0.17.0}/.opencode/opencode.json +0 -0
  40. {arrayview-0.16.0 → arrayview-0.17.0}/.python-version +0 -0
  41. {arrayview-0.16.0 → arrayview-0.17.0}/.vscode/settings.json +0 -0
  42. {arrayview-0.16.0 → arrayview-0.17.0}/AGENTS.md +0 -0
  43. {arrayview-0.16.0 → arrayview-0.17.0}/CONTRIBUTING.md +0 -0
  44. {arrayview-0.16.0 → arrayview-0.17.0}/IMMERSIVE_ANIMATION.md +0 -0
  45. {arrayview-0.16.0 → arrayview-0.17.0}/LICENSE +0 -0
  46. {arrayview-0.16.0 → arrayview-0.17.0}/README.md +0 -0
  47. {arrayview-0.16.0 → arrayview-0.17.0}/docs/comparing.md +0 -0
  48. {arrayview-0.16.0 → arrayview-0.17.0}/docs/configuration.md +0 -0
  49. {arrayview-0.16.0 → arrayview-0.17.0}/docs/display.md +0 -0
  50. {arrayview-0.16.0 → arrayview-0.17.0}/docs/index.md +0 -0
  51. {arrayview-0.16.0 → arrayview-0.17.0}/docs/loading.md +0 -0
  52. {arrayview-0.16.0 → arrayview-0.17.0}/docs/logo.png +0 -0
  53. {arrayview-0.16.0 → arrayview-0.17.0}/docs/measurement.md +0 -0
  54. {arrayview-0.16.0 → arrayview-0.17.0}/docs/remote.md +0 -0
  55. {arrayview-0.16.0 → arrayview-0.17.0}/docs/stylesheets/extra.css +0 -0
  56. {arrayview-0.16.0 → arrayview-0.17.0}/docs/viewing.md +0 -0
  57. {arrayview-0.16.0 → arrayview-0.17.0}/matlab/arrayview.m +0 -0
  58. {arrayview-0.16.0 → arrayview-0.17.0}/mkdocs.yml +0 -0
  59. {arrayview-0.16.0 → arrayview-0.17.0}/plans/2026-04-14-immersive-animation.md +0 -0
  60. {arrayview-0.16.0 → arrayview-0.17.0}/plans/webview/LOG.md +0 -0
  61. {arrayview-0.16.0 → arrayview-0.17.0}/scripts/demo.py +0 -0
  62. {arrayview-0.16.0 → arrayview-0.17.0}/scripts/release.sh +0 -0
  63. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/ARCHITECTURE.md +0 -0
  64. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/__init__.py +0 -0
  65. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/__main__.py +0 -0
  66. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_app.py +0 -0
  67. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_config.py +0 -0
  68. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_icon.png +0 -0
  69. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_io.py +0 -0
  70. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_launcher.py +0 -0
  71. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_platform.py +0 -0
  72. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_render.py +0 -0
  73. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_segmentation.py +0 -0
  74. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_session.py +0 -0
  75. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_shell.html +0 -0
  76. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_torch.py +0 -0
  77. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_vscode.py +0 -0
  78. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/arrayview-opener.vsix +0 -0
  79. {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/gsap.min.js +0 -0
  80. {arrayview-0.16.0 → arrayview-0.17.0}/tests/conftest.py +0 -0
  81. {arrayview-0.16.0 → arrayview-0.17.0}/tests/make_vectorfield_test_arrays.py +0 -0
  82. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_api.py +0 -0
  83. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_cli.py +0 -0
  84. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_command_reachability.py +0 -0
  85. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_config.py +0 -0
  86. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_cross_mode_parametrized.py +0 -0
  87. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_large_arrays.py +0 -0
  88. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_loading_server.py +0 -0
  89. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_mode_consistency.py +0 -0
  90. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_mode_matrix.py +0 -0
  91. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_nifti_meta.py +0 -0
  92. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_rgb_pixel_art.py +0 -0
  93. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_torch.py +0 -0
  94. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_view_component_integration.py +0 -0
  95. {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_view_component_unit.py +0 -0
  96. {arrayview-0.16.0 → arrayview-0.17.0}/tests/ui_audit.py +0 -0
  97. {arrayview-0.16.0 → arrayview-0.17.0}/vscode-extension/AGENTS.md +0 -0
  98. {arrayview-0.16.0 → arrayview-0.17.0}/vscode-extension/LICENSE +0 -0
  99. {arrayview-0.16.0 → arrayview-0.17.0}/vscode-extension/extension.js +0 -0
  100. {arrayview-0.16.0 → arrayview-0.17.0}/vscode-extension/package.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayview
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: Fast multi-dimensional array viewer
5
5
  Project-URL: Home, https://github.com/oscarvanderheide/arrayview
6
6
  Project-URL: Source, https://github.com/oscarvanderheide/arrayview
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.16.0"
7
+ version = "0.17.0"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -12,6 +12,7 @@ import json
12
12
  import math
13
13
  import os
14
14
  import threading
15
+ from datetime import datetime, timezone
15
16
 
16
17
  import numpy as np
17
18
  from fastapi import (
@@ -254,6 +255,243 @@ def _composite_overlays(
254
255
  return rgba
255
256
 
256
257
 
258
+ # ── Oblique Preset Helpers ────────────────────────────────────────
259
+ #
260
+ # Clients in 3-plane multiview can persist the current oblique basis as a
261
+ # "recent" preset that survives across sessions. Storage is a single JSON file
262
+ # under ``~/.arrayview/oblique_recent.json`` — independent of any per-session
263
+ # state so the preset can be reused across arrays with compatible shapes.
264
+
265
+
266
+ _OBLIQUE_RECENT_FILE = os.path.expanduser("~/.arrayview/oblique_recent.json")
267
+ _OBLIQUE_LOCK = threading.Lock()
268
+
269
+
270
+ def _utc_now_iso() -> str:
271
+ return datetime.now(timezone.utc).isoformat()
272
+
273
+
274
+ def _clamp_int(value, lo: int, hi: int, fallback: int) -> int:
275
+ try:
276
+ v = int(value)
277
+ except Exception:
278
+ v = int(fallback)
279
+ if v < lo:
280
+ return lo
281
+ if v > hi:
282
+ return hi
283
+ return v
284
+
285
+
286
+ def _normalize_oblique_preset(
287
+ preset: dict | None,
288
+ *,
289
+ ndim: int,
290
+ shape: tuple[int, ...],
291
+ ) -> dict | None:
292
+ """Validate + clamp an oblique preset payload against the session shape.
293
+
294
+ Returns the sanitized preset, or None if required fields are missing /
295
+ malformed. Accepts any shape whose rank matches ``ndim`` — the caller
296
+ may enforce stricter shape-compatibility if desired.
297
+ """
298
+ if not isinstance(preset, dict):
299
+ return None
300
+ try:
301
+ shape_out = [int(v) for v in preset.get("shape", list(shape))]
302
+ except Exception:
303
+ return None
304
+ if len(shape_out) != ndim:
305
+ return None
306
+ if any(int(v) <= 0 for v in shape_out):
307
+ return None
308
+
309
+ try:
310
+ mv_dims = [int(v) for v in preset.get("mv_dims", [])]
311
+ except Exception:
312
+ return None
313
+ if len(mv_dims) != 3 or len(set(mv_dims)) != 3:
314
+ return None
315
+ if any((d < 0 or d >= ndim) for d in mv_dims):
316
+ return None
317
+
318
+ try:
319
+ indices = [int(v) for v in preset.get("indices", [])]
320
+ except Exception:
321
+ return None
322
+ if len(indices) != ndim:
323
+ return None
324
+ for d in range(ndim):
325
+ n = max(1, int(shape_out[d]))
326
+ indices[d] = _clamp_int(indices[d], 0, n - 1, n // 2)
327
+
328
+ vecs_raw = preset.get("oblique_vecs")
329
+ if not isinstance(vecs_raw, list) or len(vecs_raw) != 3:
330
+ return None
331
+ vecs_out: list[dict[str, list[float]]] = []
332
+ for item in vecs_raw:
333
+ if not isinstance(item, dict):
334
+ return None
335
+ try:
336
+ bh = [float(v) for v in item.get("bh", [])]
337
+ bv = [float(v) for v in item.get("bv", [])]
338
+ nn = [float(v) for v in item.get("n", [])]
339
+ except Exception:
340
+ return None
341
+ if len(bh) != 3 or len(bv) != 3 or len(nn) != 3:
342
+ return None
343
+ vecs_out.append({"bh": bh, "bv": bv, "n": nn})
344
+
345
+ pane_labels = preset.get("pane_labels")
346
+ if not isinstance(pane_labels, list) or len(pane_labels) != 3:
347
+ pane_labels = ["Oblique A", "Oblique B", "Oblique C"]
348
+ pane_labels = [str(v) for v in pane_labels]
349
+
350
+ pane_defs_raw = preset.get("pane_defs")
351
+ pane_defs_out = None
352
+ if isinstance(pane_defs_raw, list) and len(pane_defs_raw) == 3:
353
+ tmp_defs: list[dict[str, int]] = []
354
+ ok_defs = True
355
+ for pd in pane_defs_raw:
356
+ if not isinstance(pd, dict):
357
+ ok_defs = False
358
+ break
359
+ try:
360
+ dx = int(pd.get("dim_x"))
361
+ dy = int(pd.get("dim_y"))
362
+ sd = int(pd.get("slice_dir"))
363
+ except Exception:
364
+ ok_defs = False
365
+ break
366
+ if any((d < 0 or d >= ndim) for d in (dx, dy, sd)):
367
+ ok_defs = False
368
+ break
369
+ if len({dx, dy, sd}) != 3:
370
+ ok_defs = False
371
+ break
372
+ tmp_defs.append({"dim_x": dx, "dim_y": dy, "slice_dir": sd})
373
+ if ok_defs:
374
+ pane_defs_out = tmp_defs
375
+
376
+ out = {
377
+ "version": int(preset.get("version", 1)),
378
+ "shape": shape_out,
379
+ "mv_dims": mv_dims,
380
+ "indices": indices,
381
+ "oblique_vecs": vecs_out,
382
+ "pane_labels": pane_labels,
383
+ }
384
+ if pane_defs_out is not None:
385
+ out["pane_defs"] = pane_defs_out
386
+ lock_raw = preset.get("oblique_ortho_lock")
387
+ if isinstance(lock_raw, bool):
388
+ out["oblique_ortho_lock"] = lock_raw
389
+ return out
390
+
391
+
392
+ def _load_recent_oblique_file(path: str, *, ndim: int, shape: tuple[int, ...]) -> dict | None:
393
+ try:
394
+ with open(path, "r", encoding="utf-8") as f:
395
+ data = json.load(f)
396
+ except Exception:
397
+ return None
398
+ return _normalize_oblique_preset(data, ndim=ndim, shape=shape)
399
+
400
+
401
+ def _write_recent_oblique_file(
402
+ path: str, session: "Session", preset: dict
403
+ ) -> tuple[bool, str | None]:
404
+ try:
405
+ folder = os.path.dirname(path)
406
+ if folder:
407
+ os.makedirs(folder, exist_ok=True)
408
+ payload = dict(preset)
409
+ payload["saved_at"] = _utc_now_iso()
410
+ payload["sid"] = getattr(session, "sid", None)
411
+ payload["name"] = getattr(session, "name", "") or ""
412
+ with open(path, "w", encoding="utf-8") as f:
413
+ json.dump(payload, f, indent=2)
414
+ return True, None
415
+ except Exception as exc:
416
+ return False, str(exc)
417
+
418
+
419
+ # ── Crop Plugin State ─────────────────────────────────────────────
420
+ #
421
+ # The crop plugin tracks per-session state (selected x-range along the readout
422
+ # dim, visualisation slice indices, confirmation flag, optional recent-file
423
+ # path). State is held in memory keyed by sid; on confirm it may persist to
424
+ # ``~/.arrayview/crop_recent.json`` so external pipelines (e.g.
425
+ # reconstruction scripts) can reuse the last user-confirmed crop.
426
+
427
+ _CROP_RECENT_FILE = os.path.expanduser(
428
+ "~/.arrayview/crop_recent.json"
429
+ )
430
+ _CROP_LOCK = threading.Lock()
431
+ _CROP_STATE: dict[str, dict] = {}
432
+
433
+
434
+ def _clamp_crop_range(x_start, x_end, nx: int) -> tuple[int, int]:
435
+ nx = max(1, int(nx))
436
+ default_start = max(0, nx // 4)
437
+ default_end = min(nx, (3 * nx) // 4)
438
+ if default_end <= default_start:
439
+ default_end = min(nx, default_start + 1)
440
+ xs = _clamp_int(x_start, 0, nx - 1, default_start)
441
+ xe = _clamp_int(x_end, 1, nx, default_end)
442
+ if xe <= xs:
443
+ xe = min(nx, xs + 1)
444
+ if xs >= xe:
445
+ xs = max(0, xe - 1)
446
+ return int(xs), int(xe)
447
+
448
+
449
+ def _load_recent_crop_file(path: str, nx: int) -> dict | None:
450
+ try:
451
+ with open(path, "r", encoding="utf-8") as f:
452
+ data = json.load(f)
453
+ if not isinstance(data, dict):
454
+ return None
455
+ xs, xe = _clamp_crop_range(data.get("x_start"), data.get("x_end"), nx)
456
+ out: dict = {"x_start": xs, "x_end": xe}
457
+ if "viz_z" in data:
458
+ try:
459
+ out["viz_z"] = int(data["viz_z"])
460
+ except Exception:
461
+ pass
462
+ if "viz_y" in data:
463
+ try:
464
+ out["viz_y"] = int(data["viz_y"])
465
+ except Exception:
466
+ pass
467
+ return out
468
+ except Exception:
469
+ return None
470
+
471
+
472
+ def _write_recent_crop_file(path: str, state: dict) -> tuple[bool, str | None]:
473
+ try:
474
+ folder = os.path.dirname(path)
475
+ if folder:
476
+ os.makedirs(folder, exist_ok=True)
477
+ payload = {
478
+ "x_start": int(state["x_start"]),
479
+ "x_end": int(state["x_end"]),
480
+ "nx": int(state["nx"]),
481
+ "readout_dim": int(state.get("readout_dim", -1)),
482
+ "viz_z": int(state.get("viz_z", -1)),
483
+ "viz_y": int(state.get("viz_y", -1)),
484
+ "saved_at": _utc_now_iso(),
485
+ "sid": state.get("sid"),
486
+ "name": state.get("name", ""),
487
+ }
488
+ with open(path, "w", encoding="utf-8") as f:
489
+ json.dump(payload, f, indent=2)
490
+ return True, None
491
+ except Exception as exc:
492
+ return False, str(exc)
493
+
494
+
257
495
  # ── FastAPI Application ───────────────────────────────────────────
258
496
 
259
497
  app = FastAPI()
@@ -333,6 +571,8 @@ async def shell_websocket(ws: WebSocket):
333
571
  SESSIONS[sid].reset_caches()
334
572
  SESSIONS[sid].data = None
335
573
  del SESSIONS[sid]
574
+ with _CROP_LOCK:
575
+ _CROP_STATE.pop(sid, None)
336
576
  except Exception:
337
577
  pass
338
578
  finally:
@@ -3648,6 +3888,326 @@ async def load_bytes_endpoint(request: Request):
3648
3888
  return {"sid": session.sid, "url": url}
3649
3889
 
3650
3890
 
3891
+ # ── Oblique Preset Routes ─────────────────────────────────────────
3892
+ #
3893
+ # These are intentionally NOT namespaced under any plugin — the oblique basis
3894
+ # is a property of the 3-plane multiview mode itself. Presets persist to a
3895
+ # single JSON file under ~/.arrayview so users can share an orientation
3896
+ # across sessions / arrays that happen to have the same rank.
3897
+
3898
+
3899
+ @app.post("/oblique/save")
3900
+ async def oblique_save(request: Request):
3901
+ """Persist the current oblique basis as the 'recent' preset.
3902
+
3903
+ Body: ``{"sid": "<sid>", "preset": {...}}``.
3904
+ """
3905
+ body = await request.json()
3906
+ sid = str(body.get("sid") or "").strip()
3907
+ if not sid:
3908
+ return JSONResponse({"error": "missing_sid"}, status_code=400)
3909
+ session = SESSIONS.get(sid)
3910
+ if session is None:
3911
+ return JSONResponse({"error": "session_not_found"}, status_code=404)
3912
+ shape = tuple(int(v) for v in getattr(session, "shape", ()) or ())
3913
+ ndim = len(shape)
3914
+ if ndim <= 0:
3915
+ return JSONResponse({"error": "invalid_session_shape"}, status_code=500)
3916
+ preset = _normalize_oblique_preset(body.get("preset"), ndim=ndim, shape=shape)
3917
+ if preset is None:
3918
+ return JSONResponse({"error": "invalid_oblique_preset"}, status_code=400)
3919
+ with _OBLIQUE_LOCK:
3920
+ ok, err = _write_recent_oblique_file(_OBLIQUE_RECENT_FILE, session, preset)
3921
+ if not ok:
3922
+ return JSONResponse(
3923
+ {"error": "oblique_save_failed", "detail": str(err)}, status_code=500
3924
+ )
3925
+ return JSONResponse(
3926
+ {
3927
+ "ok": True,
3928
+ "sid": sid,
3929
+ "saved_path": _OBLIQUE_RECENT_FILE,
3930
+ "preset": preset,
3931
+ }
3932
+ )
3933
+
3934
+
3935
+ @app.post("/oblique/load_recent")
3936
+ async def oblique_load_recent(request: Request):
3937
+ """Return the most recently saved oblique preset, clamped to this session's shape.
3938
+
3939
+ Body: ``{"sid": "<sid>"}``.
3940
+ """
3941
+ body = await request.json()
3942
+ sid = str(body.get("sid") or "").strip()
3943
+ if not sid:
3944
+ return JSONResponse({"error": "missing_sid"}, status_code=400)
3945
+ session = SESSIONS.get(sid)
3946
+ if session is None:
3947
+ return JSONResponse({"error": "session_not_found"}, status_code=404)
3948
+ shape = tuple(int(v) for v in getattr(session, "shape", ()) or ())
3949
+ ndim = len(shape)
3950
+ if ndim <= 0:
3951
+ return JSONResponse({"error": "invalid_session_shape"}, status_code=500)
3952
+ with _OBLIQUE_LOCK:
3953
+ preset = _load_recent_oblique_file(
3954
+ _OBLIQUE_RECENT_FILE, ndim=ndim, shape=shape
3955
+ )
3956
+ if preset is None:
3957
+ return JSONResponse(
3958
+ {"error": "oblique_recent_file_missing_or_invalid"}, status_code=404
3959
+ )
3960
+ return JSONResponse(
3961
+ {
3962
+ "ok": True,
3963
+ "sid": sid,
3964
+ "path": _OBLIQUE_RECENT_FILE,
3965
+ "preset": preset,
3966
+ }
3967
+ )
3968
+
3969
+
3970
+ # ── Crop Plugin Routes ────────────────────────────────────────────
3971
+ #
3972
+ # The crop plugin lets users pick an x-range along one dimension (the "readout
3973
+ # dim") and a pair of visualisation slice indices. External pipelines read the
3974
+ # confirmed state to crop raw data before reconstruction. State lives in
3975
+ # :data:`_CROP_STATE` keyed by sid; confirmed crops optionally persist
3976
+ # to :data:`_CROP_RECENT_FILE` so follow-up runs can reuse the choice.
3977
+
3978
+
3979
+ @app.post("/crop/register")
3980
+ async def crop_register(request: Request):
3981
+ """Start (or re-start) a crop session for ``sid``.
3982
+
3983
+ Body: ``{"sid": "<sid>", "readout_dim": int?, "x_start": int?, "x_end": int?,
3984
+ "viz_z": int?, "viz_y": int?, "recent_file": str?}``. Values are clamped
3985
+ to the session's shape; when ``recent_file`` is provided and parsable the
3986
+ saved ``x_start/x_end/viz_z/viz_y`` seed the new state.
3987
+ """
3988
+ body = await request.json()
3989
+ sid = str(body.get("sid") or "").strip()
3990
+ if not sid:
3991
+ return JSONResponse({"error": "missing_sid"}, status_code=400)
3992
+ session = SESSIONS.get(sid)
3993
+ if session is None:
3994
+ return JSONResponse({"error": "session_not_found"}, status_code=404)
3995
+
3996
+ shape = tuple(int(s) for s in getattr(session, "spatial_shape", ()) or ())
3997
+ if not shape:
3998
+ shape = tuple(int(s) for s in getattr(session, "shape", ()) or ())
3999
+ ndim = len(shape)
4000
+ if ndim <= 0:
4001
+ return JSONResponse({"error": "invalid_session_shape"}, status_code=500)
4002
+
4003
+ readout_dim = _clamp_int(
4004
+ body.get("readout_dim", ndim - 1), 0, max(0, ndim - 1), ndim - 1
4005
+ )
4006
+ nx = int(shape[readout_dim])
4007
+ xs, xe = _clamp_crop_range(body.get("x_start"), body.get("x_end"), nx)
4008
+
4009
+ non_readout_dims = [d for d in range(ndim) if d != readout_dim]
4010
+ default_viz_z_dim = non_readout_dims[0] if non_readout_dims else -1
4011
+ default_viz_y_dim = non_readout_dims[1] if len(non_readout_dims) > 1 else -1
4012
+ viz_z = int(
4013
+ body.get(
4014
+ "viz_z",
4015
+ shape[default_viz_z_dim] // 2 if default_viz_z_dim >= 0 else -1,
4016
+ )
4017
+ )
4018
+ viz_y = int(
4019
+ body.get(
4020
+ "viz_y",
4021
+ shape[default_viz_y_dim] // 2 if default_viz_y_dim >= 0 else -1,
4022
+ )
4023
+ )
4024
+
4025
+ raw_recent = body.get("recent_file")
4026
+ recent_file = str(raw_recent).strip() if raw_recent is not None else ""
4027
+ if not recent_file:
4028
+ recent_file = _CROP_RECENT_FILE
4029
+ loaded_recent = False
4030
+ if recent_file:
4031
+ recent = _load_recent_crop_file(recent_file, nx)
4032
+ if recent is not None:
4033
+ xs, xe = int(recent["x_start"]), int(recent["x_end"])
4034
+ if "viz_z" in recent:
4035
+ viz_z = int(recent["viz_z"])
4036
+ if "viz_y" in recent:
4037
+ viz_y = int(recent["viz_y"])
4038
+ loaded_recent = True
4039
+
4040
+ state = {
4041
+ "sid": sid,
4042
+ "name": str(body.get("name") or getattr(session, "name", "") or ""),
4043
+ "shape": list(shape),
4044
+ "readout_dim": int(readout_dim),
4045
+ "nx": int(nx),
4046
+ "x_start": int(xs),
4047
+ "x_end": int(xe),
4048
+ "viz_z": int(viz_z),
4049
+ "viz_y": int(viz_y),
4050
+ "confirmed": False,
4051
+ "save_requested": False,
4052
+ "saved_recent": False,
4053
+ "saved_recent_path": None,
4054
+ "recent_file": recent_file,
4055
+ "loaded_recent": bool(loaded_recent),
4056
+ "updated_at": _utc_now_iso(),
4057
+ }
4058
+ with _CROP_LOCK:
4059
+ _CROP_STATE[sid] = state
4060
+ return JSONResponse(state)
4061
+
4062
+
4063
+ @app.get("/crop/state/{sid}")
4064
+ def crop_state(sid: str):
4065
+ """Return the current in-memory crop state for ``sid``."""
4066
+ with _CROP_LOCK:
4067
+ state = _CROP_STATE.get(sid)
4068
+ if state is None:
4069
+ return JSONResponse(
4070
+ {"error": "crop_session_not_found"}, status_code=404
4071
+ )
4072
+ return JSONResponse(dict(state))
4073
+
4074
+
4075
+ @app.post("/crop/update")
4076
+ async def crop_update(request: Request):
4077
+ """Update the crop range (and viz slice indices) for ``sid``.
4078
+
4079
+ Body: ``{"sid": "<sid>", "x_start": int, "x_end": int, "viz_z": int?,
4080
+ "viz_y": int?, "readout_dim": int?}``.
4081
+
4082
+ When ``readout_dim`` changes, ``nx`` is re-derived from the session's
4083
+ shape along the new dim and the range is re-seeded to the full extent
4084
+ (``0..nx``) unless explicit ``x_start``/``x_end`` are provided in the
4085
+ same body.
4086
+ """
4087
+ body = await request.json()
4088
+ sid = str(body.get("sid") or "").strip()
4089
+ if not sid:
4090
+ return JSONResponse({"error": "missing_sid"}, status_code=400)
4091
+ with _CROP_LOCK:
4092
+ state = _CROP_STATE.get(sid)
4093
+ if state is None:
4094
+ return JSONResponse(
4095
+ {"error": "crop_session_not_found"}, status_code=404
4096
+ )
4097
+ # Optional readout-dim change — re-derive nx from session shape and
4098
+ # reset the range to full extent unless the client provides one.
4099
+ if "readout_dim" in body:
4100
+ shape = tuple(int(s) for s in state.get("shape", ()) or ())
4101
+ ndim = len(shape)
4102
+ if ndim > 0:
4103
+ new_rd = _clamp_int(
4104
+ body.get("readout_dim"),
4105
+ 0,
4106
+ max(0, ndim - 1),
4107
+ int(state.get("readout_dim", ndim - 1)),
4108
+ )
4109
+ if new_rd != int(state.get("readout_dim", -1)):
4110
+ new_nx = int(shape[new_rd])
4111
+ state["readout_dim"] = int(new_rd)
4112
+ state["nx"] = int(new_nx)
4113
+ # Re-seed to full range; client can override via x_start/x_end.
4114
+ if "x_start" not in body and "x_end" not in body:
4115
+ state["x_start"] = 0
4116
+ state["x_end"] = int(new_nx)
4117
+ state["confirmed"] = False
4118
+ state["loaded_recent"] = False
4119
+ xs, xe = _clamp_crop_range(
4120
+ body.get("x_start", state.get("x_start")),
4121
+ body.get("x_end", state.get("x_end")),
4122
+ int(state["nx"]),
4123
+ )
4124
+ state["x_start"] = int(xs)
4125
+ state["x_end"] = int(xe)
4126
+ if "viz_z" in body:
4127
+ state["viz_z"] = int(body.get("viz_z", state.get("viz_z", -1)))
4128
+ if "viz_y" in body:
4129
+ state["viz_y"] = int(body.get("viz_y", state.get("viz_y", -1)))
4130
+ state["updated_at"] = _utc_now_iso()
4131
+ return JSONResponse(dict(state))
4132
+
4133
+
4134
+ @app.post("/crop/load_recent")
4135
+ async def crop_load_recent(request: Request):
4136
+ """Overlay the saved recent crop onto the current state (if any).
4137
+
4138
+ Body: ``{"sid": "<sid>"}``. Returns 404 if no saved crop is available.
4139
+ """
4140
+ body = await request.json()
4141
+ sid = str(body.get("sid") or "").strip()
4142
+ if not sid:
4143
+ return JSONResponse({"error": "missing_sid"}, status_code=400)
4144
+ with _CROP_LOCK:
4145
+ state = _CROP_STATE.get(sid)
4146
+ if state is None:
4147
+ return JSONResponse(
4148
+ {"error": "crop_session_not_found"}, status_code=404
4149
+ )
4150
+ recent_file = state.get("recent_file") or _CROP_RECENT_FILE
4151
+ recent = _load_recent_crop_file(str(recent_file), int(state["nx"]))
4152
+ if recent is None:
4153
+ return JSONResponse(
4154
+ {"error": "recent_file_missing_or_invalid"}, status_code=404
4155
+ )
4156
+ state["x_start"] = int(recent["x_start"])
4157
+ state["x_end"] = int(recent["x_end"])
4158
+ if "viz_z" in recent:
4159
+ state["viz_z"] = int(recent["viz_z"])
4160
+ if "viz_y" in recent:
4161
+ state["viz_y"] = int(recent["viz_y"])
4162
+ state["loaded_recent"] = True
4163
+ state["updated_at"] = _utc_now_iso()
4164
+ return JSONResponse(dict(state))
4165
+
4166
+
4167
+ @app.post("/crop/confirm")
4168
+ async def crop_confirm(request: Request):
4169
+ """Mark the current crop as confirmed and persist to the recent-file.
4170
+
4171
+ Body: ``{"sid": "<sid>"}``. Confirm always writes the recent-file —
4172
+ the UI no longer offers a confirm-without-save path.
4173
+ """
4174
+ body = await request.json()
4175
+ sid = str(body.get("sid") or "").strip()
4176
+ if not sid:
4177
+ return JSONResponse({"error": "missing_sid"}, status_code=400)
4178
+ with _CROP_LOCK:
4179
+ state = _CROP_STATE.get(sid)
4180
+ if state is None:
4181
+ return JSONResponse(
4182
+ {"error": "crop_session_not_found"}, status_code=404
4183
+ )
4184
+ state["confirmed"] = True
4185
+ state["save_requested"] = True
4186
+ state["saved_recent"] = False
4187
+ state["saved_recent_path"] = None
4188
+ state["updated_at"] = _utc_now_iso()
4189
+ target = state.get("recent_file") or _CROP_RECENT_FILE
4190
+ ok, err = _write_recent_crop_file(str(target), state)
4191
+ if ok:
4192
+ state["saved_recent"] = True
4193
+ state["saved_recent_path"] = str(target)
4194
+ else:
4195
+ state["save_error"] = err
4196
+ return JSONResponse(dict(state))
4197
+
4198
+
4199
+ @app.post("/crop/clear")
4200
+ async def crop_clear(request: Request):
4201
+ """Drop the in-memory crop state for ``sid`` (no-op if absent)."""
4202
+ body = await request.json()
4203
+ sid = str(body.get("sid") or "").strip()
4204
+ if not sid:
4205
+ return JSONResponse({"error": "missing_sid"}, status_code=400)
4206
+ with _CROP_LOCK:
4207
+ _CROP_STATE.pop(sid, None)
4208
+ return JSONResponse({"ok": True, "sid": sid})
4209
+
4210
+
3651
4211
  # ── Root UI Route ─────────────────────────────────────────────────
3652
4212
 
3653
4213
 
@@ -925,7 +925,7 @@ def _handle_get_viewer_html(msg: dict) -> None:
925
925
  # not the FastAPI server. Inline the vendored copy instead.
926
926
  gsap_js = _pkg_files("arrayview").joinpath("gsap.min.js").read_text(encoding="utf-8")
927
927
  template = template.replace(
928
- '<script src="/gsap.min.js"></script>',
928
+ '<script src="gsap.min.js"></script>',
929
929
  f"<script>{gsap_js}</script>",
930
930
  )
931
931