# Project export: 3D Point cloud Construction

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: This build keeps the existing monument test cases and adds an improved Custom monument PLY upload workflow.
- Devpost: https://devpost.com/software/3d-point-cloud-construction
- GitHub: https://github.com/hvhasabnis/berkeley_hackathon
- Team: 2 GitHub contributor(s) — hvhasabnis (3 commits), Yasin Şensoy (2 commits)

## Devpost submission (written by the team)

### Overview

A point cloud reconstruction tool that repairs and completes damaged 3D monument scans. It ships with built-in test monuments and also accepts your own custom .ply uploads. Test Subjects Built-in Test Cases Each built-in monument includes four deterministic test cases, including the deliberately extreme non-symmetric half-chop demolition. Case 1 — Local demolition Case 2 — Second demolition Case 3 — Diagonal / severe damage Case 4 — Absurd half-chop demolition Custom Upload Modes When you select Test subject → Custom monument (upload PLY), the panel reveals a Custom reconstruction mode control: Leaning or slanted objects: Balanced and Aggressive apply an adaptive height-wise centerline before symmetry reflection. This reconstructs uploaded towers and slanted monuments more accurately than the older rigid vertical-plane method. Supported PLY Files ASCII PLY with x y z vertices Binary little-endian PLY with x y z vertices Binary big-endian PLY with x y z vertices Getting Started Then open: In the browser Test subject → Custom monument (upload PLY) Custom reconstruction mode → Balanced or Aggressive Upload your .ply file View → Reconstructed particles or Completed cloud Changing the custom mode reprocesses the uploaded PLY directly in the browser. It does not overwrite the Roman Arena, Palmyra Arch, or Leaning Tower outputs. Notes Custom PLY reconstruction is generic — it doesn't know the monument type, so results depend on how symmetric and clean your uploaded point cloud is. Balanced is the best default for most uploaded objects. Use Aggressive when the red reconstruction comes out too sparse.

## README (from the GitHub repository)

# berkeley_hackathon


<img width="1344" height="703" alt="Ekran Resmi 2026-06-21 11 59 38" src="https://github.com/user-attachments/assets/c45aca75-7074-4f30-9983-1cd915711800" />

This build keeps the existing monument test cases and adds an improved **Custom monument PLY upload** workflow.

The core reconstruction pipeline remains unchanged:

```text
python/completion_pipeline.py
```

## Included subjects

```text
Broken Roman Arena
Broken Palmyra Arch
Demolished Leaning Tower of Pisa
Custom monument (upload PLY)
```

## Built-in test cases

Each built-in monument includes four deterministic test cases, including the absurd non-symmetric half-chop demolition case.

```text
Case 1 - local demolition case
Case 2 - second demolition case
Case 3 - diagonal/severe damage case
Case 4 - Absurd half-chop demolition
```

## New custom upload control

When you select:

```text
Test subject -> Custom monument (upload PLY)
```

the panel now shows:

```text
Custom reconstruction mode:
- Conservative
- Balanced
- Aggressive
```

Use them like this:

```text
Conservative = safest, fewer red particles
Balanced     = recommended default
Aggressive   = fills larger missing regions, may add more red particles
```

For leaning or slanted custom objects, Balanced and Aggressive use an adaptive height-wise centerline before symmetry reflection. This makes uploaded towers or slanted monuments reconstruct better than the older rigid vertical-plane method.

## Supported PLY files

```text
ASCII PLY with x y z vertices
Binary little-endian PLY with x y z vertices
Binary big-endian PLY with x y z vertices
```

## Run

```bash
cd "Yourpath\multi-monument-completion-custom-upload-modes"
py -m pip install -r python/requirements.txt
py python/run_subject.py --all --regenerate
py -m http.server 8000
```

Open:

```text
http://localhost:8000/index.html
```

Then choose:

```text
Test subject -> Custom monument (upload PLY)
Custom reconstruction mode -> Balanced or Aggressive
Upload your .ply file
View -> Reconstructed particles or Completed cloud
```

Changing the custom mode reprocesses the uploaded PLY in the browser. It does not overwrite Roman Arena, Palmyra Arch, or Leaning Tower outputs.

## Notes

Custom PLY reconstruction is generic. It does not know the monument type, so results depend on how symmetric and clean the uploaded point cloud is. Balanced is the best default for most uploaded objects. Use Aggressive when the red reconstruction is too sparse.


## Detected evidence (automated analysis)

Indexed codebase: 13 recognized source files, 176 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- Flask (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Python (language) — detected in the code

## Codebase structure (from repository index)

### Files (120 of 162)

```
.DS_Store
geometrical_symmetry_method/.DS_Store
geometrical_symmetry_method/completed_viewer.html
geometrical_symmetry_method/data/leaning_tower/absurd_half_chop_missing.csv
geometrical_symmetry_method/data/leaning_tower/absurd_half_chop.csv
geometrical_symmetry_method/data/leaning_tower/diagonal_tower_crack_missing.csv
geometrical_symmetry_method/data/leaning_tower/diagonal_tower_crack.csv
geometrical_symmetry_method/data/leaning_tower/lower_arcade_breach_missing.csv
geometrical_symmetry_method/data/leaning_tower/lower_arcade_breach.csv
geometrical_symmetry_method/data/leaning_tower/upper_bell_collapse_missing.csv
geometrical_symmetry_method/data/leaning_tower/upper_bell_collapse.csv
geometrical_symmetry_method/data/palmyra_arch/absurd_half_chop_missing.csv
geometrical_symmetry_method/data/palmyra_arch/absurd_half_chop.csv
geometrical_symmetry_method/data/palmyra_arch/crown_column_blast_missing.csv
geometrical_symmetry_method/data/palmyra_arch/crown_column_blast.csv
geometrical_symmetry_method/data/palmyra_arch/left_upper_breach_missing.csv
geometrical_symmetry_method/data/palmyra_arch/left_upper_breach.csv
geometrical_symmetry_method/data/palmyra_arch/right_pier_shear_missing.csv
geometrical_symmetry_method/data/palmyra_arch/right_pier_shear.csv
geometrical_symmetry_method/data/roman_arena/absurd_half_chop_missing.csv
geometrical_symmetry_method/data/roman_arena/absurd_half_chop.csv
geometrical_symmetry_method/data/roman_arena/baseline_demolition_missing.csv
geometrical_symmetry_method/data/roman_arena/baseline_demolition.csv
geometrical_symmetry_method/data/roman_arena/diagonal_collapse_missing.csv
geometrical_symmetry_method/data/roman_arena/diagonal_collapse.csv
geometrical_symmetry_method/data/roman_arena/north_rim_blast_missing.csv
geometrical_symmetry_method/data/roman_arena/north_rim_blast.csv
geometrical_symmetry_method/data/source/leaning_tower_complete.csv
geometrical_symmetry_method/data/source/palmyra_arch_complete.csv
geometrical_symmetry_method/data/source/roman_arena_complete.csv
geometrical_symmetry_method/index.html
geometrical_symmetry_method/js/splash.js
geometrical_symmetry_method/js/viewer.js
geometrical_symmetry_method/output/agent/roman_arena/baseline_demolition/balanced/completed.csv
geometrical_symmetry_method/output/agent/roman_arena/baseline_demolition/balanced/pinc_clean.csv
geometrical_symmetry_method/output/agent/roman_arena/baseline_demolition/balanced/pinc_display_clean.csv
geometrical_symmetry_method/output/agent/roman_arena/baseline_demolition/balanced/pref_reflected_after_icp.csv
geometrical_symmetry_method/output/agent/roman_arena/baseline_demolition/balanced/pref_reflected_before_icp.csv
geometrical_symmetry_method/output/agent/roman_arena/baseline_demolition/balanced/reconstructed_only.csv
geometrical_symmetry_method/output/agent/roman_arena/baseline_demolition/balanced/report.json
geometrical_symmetry_method/output/leaning_tower/absurd_half_chop/core/report.json
geometrical_symmetry_method/output/leaning_tower/absurd_half_chop/viewer/completed.csv
geometrical_symmetry_method/output/leaning_tower/absurd_half_chop/viewer/pinc_clean.csv
geometrical_symmetry_method/output/leaning_tower/absurd_half_chop/viewer/pinc_display_clean.csv
geometrical_symmetry_method/output/leaning_tower/absurd_half_chop/viewer/reconstructed_only.csv
geometrical_symmetry_method/output/leaning_tower/absurd_half_chop/viewer/report.json
geometrical_symmetry_method/output/leaning_tower/diagonal_tower_crack/core/report.json
geometrical_symmetry_method/output/leaning_tower/diagonal_tower_crack/viewer/completed.csv
geometrical_symmetry_method/output/leaning_tower/diagonal_tower_crack/viewer/pinc_clean.csv
geometrical_symmetry_method/output/leaning_tower/diagonal_tower_crack/viewer/pinc_display_clean.csv
geometrical_symmetry_method/output/leaning_tower/diagonal_tower_crack/viewer/reconstructed_only.csv
geometrical_symmetry_method/output/leaning_tower/diagonal_tower_crack/viewer/report.json
geometrical_symmetry_method/output/leaning_tower/lower_arcade_breach/core/report.json
geometrical_symmetry_method/output/leaning_tower/lower_arcade_breach/viewer/completed.csv
geometrical_symmetry_method/output/leaning_tower/lower_arcade_breach/viewer/pinc_clean.csv
geometrical_symmetry_method/output/leaning_tower/lower_arcade_breach/viewer/pinc_display_clean.csv
geometrical_symmetry_method/output/leaning_tower/lower_arcade_breach/viewer/reconstructed_only.csv
geometrical_symmetry_method/output/leaning_tower/lower_arcade_breach/viewer/report.json
geometrical_symmetry_method/output/leaning_tower/upper_bell_collapse/core/report.json
geometrical_symmetry_method/output/leaning_tower/upper_bell_collapse/viewer/completed.csv
geometrical_symmetry_method/output/leaning_tower/upper_bell_collapse/viewer/pinc_clean.csv
geometrical_symmetry_method/output/leaning_tower/upper_bell_collapse/viewer/pinc_display_clean.csv
geometrical_symmetry_method/output/leaning_tower/upper_bell_collapse/viewer/reconstructed_only.csv
geometrical_symmetry_method/output/leaning_tower/upper_bell_collapse/viewer/report.json
geometrical_symmetry_method/output/palmyra_arch/absurd_half_chop/core/report.json
geometrical_symmetry_method/output/palmyra_arch/absurd_half_chop/viewer/completed.csv
geometrical_symmetry_method/output/palmyra_arch/absurd_half_chop/viewer/pinc_clean.csv
geometrical_symmetry_method/output/palmyra_arch/absurd_half_chop/viewer/pinc_display_clean.csv
geometrical_symmetry_method/output/palmyra_arch/absurd_half_chop/viewer/reconstructed_only.csv
geometrical_symmetry_method/output/palmyra_arch/absurd_half_chop/viewer/report.json
geometrical_symmetry_method/output/palmyra_arch/crown_column_blast/core/report.json
geometrical_symmetry_method/output/palmyra_arch/crown_column_blast/viewer/complete_reference.csv
geometrical_symmetry_method/output/palmyra_arch/crown_column_blast/viewer/completed.csv
geometrical_symmetry_method/output/palmyra_arch/crown_column_blast/viewer/pinc_clean.csv
geometrical_symmetry_method/output/palmyra_arch/crown_column_blast/viewer/pinc_display_clean.csv
geometrical_symmetry_method/output/palmyra_arch/crown_column_blast/viewer/reconstructed_only.csv
geometrical_symmetry_method/output/palmyra_arch/crown_column_blast/viewer/report.json
geometrical_symmetry_method/output/palmyra_arch/left_upper_breach/core/report.json
geometrical_symmetry_method/output/palmyra_arch/left_upper_breach/viewer/complete_reference.csv
geometrical_symmetry_method/output/palmyra_arch/left_upper_breach/viewer/completed.csv
geometrical_symmetry_method/output/palmyra_arch/left_upper_breach/viewer/pinc_clean.csv
geometrical_symmetry_method/output/palmyra_arch/left_upper_breach/viewer/pinc_display_clean.csv
geometrical_symmetry_method/output/palmyra_arch/left_upper_breach/viewer/reconstructed_only.csv
geometrical_symmetry_method/output/palmyra_arch/left_upper_breach/viewer/report.json
geometrical_symmetry_method/output/palmyra_arch/right_pier_shear/core/report.json
geometrical_symmetry_method/output/palmyra_arch/right_pier_shear/viewer/complete_reference.csv
geometrical_symmetry_method/output/palmyra_arch/right_pier_shear/viewer/completed.csv
geometrical_symmetry_method/output/palmyra_arch/right_pier_shear/viewer/pinc_clean.csv
geometrical_symmetry_method/output/palmyra_arch/right_pier_shear/viewer/pinc_display_clean.csv
geometrical_symmetry_method/output/palmyra_arch/right_pier_shear/viewer/reconstructed_only.csv
geometrical_symmetry_method/output/palmyra_arch/right_pier_shear/viewer/report.json
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/core/completed.csv
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/core/pinc_clean.csv
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/core/pinc_display_clean.csv
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/core/pref_reflected_after_icp.csv
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/core/pref_reflected_before_icp.csv
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/core/reconstructed_only.csv
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/core/report.json
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/viewer/completed.csv
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/viewer/pinc_clean.csv
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/viewer/pinc_display_clean.csv
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/viewer/pref_reflected_after_icp.csv
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/viewer/pref_reflected_before_icp.csv
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/viewer/reconstructed_only.csv
geometrical_symmetry_method/output/roman_arena/absurd_half_chop/viewer/report.json
geometrical_symmetry_method/output/roman_arena/baseline_demolition/core/completed.csv
geometrical_symmetry_method/output/roman_arena/baseline_demolition/core/pinc_clean.csv
geometrical_symmetry_method/output/roman_arena/baseline_demolition/core/pinc_display_clean.csv
geometrical_symmetry_method/output/roman_arena/baseline_demolition/core/pref_reflected_after_icp.csv
geometrical_symmetry_method/output/roman_arena/baseline_demolition/core/pref_reflected_before_icp.csv
geometrical_symmetry_method/output/roman_arena/baseline_demolition/core/reconstructed_only.csv
geometrical_symmetry_method/output/roman_arena/baseline_demolition/core/report.json
geometrical_symmetry_method/output/roman_arena/baseline_demolition/viewer/complete_reference.csv
geometrical_symmetry_method/output/roman_arena/baseline_demolition/viewer/completed.csv
geometrical_symmetry_method/output/roman_arena/baseline_demolition/viewer/pinc_clean.csv
geometrical_symmetry_method/output/roman_arena/baseline_demolition/viewer/pinc_display_clean.csv
geometrical_symmetry_method/output/roman_arena/baseline_demolition/viewer/pref_reflected_after_icp.csv
geometrical_symmetry_method/output/roman_arena/baseline_demolition/viewer/pref_reflected_before_icp.csv
geometrical_symmetry_method/output/roman_arena/baseline_demolition/viewer/reconstructed_only.csv
geometrical_symmetry_method/output/roman_arena/baseline_demolition/viewer/report.json
[42 more files omitted for size]
```

### Dependencies

- geometrical_symmetry_method/python/requirements.txt: anthropic@>=0.40, flask@>=3.0, numpy@>=1.24,<3.0, scipy@>=1.10

### Recent commits (newest first)

- Update README.md
- Update README.md
- 21.06.26
- berkeley_hackathon
- Provided code restructuring
- 20.06.2026
- Provided geometric symmetry code
- Initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### geometrical_symmetry_method/python/requirements.txt

```
numpy>=1.24,<3.0
scipy>=1.10
flask>=3.0
anthropic>=0.40

```

### geometrical_symmetry_method/completed_viewer.html

```html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Redirecting...</title>
  <script>
    const params = new URLSearchParams(window.location.search);
    params.set("view", "completed");
    window.location.replace("index.html?" + params.toString());
  </script>
</head>
<body>
  Redirecting to <a href="index.html?view=completed">single viewer</a>...
</body>
</html>

```

### geometrical_symmetry_method/reconstructed_viewer.html

```html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Redirecting...</title>
  <script>
    const params = new URLSearchParams(window.location.search);
    params.set("view", "reconstructed");
    window.location.replace("index.html?" + params.toString());
  </script>
</head>
<body>
  Redirecting to <a href="index.html?view=reconstructed">single viewer</a>...
</body>
</html>

```

### geometrical_symmetry_method/index.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
  <title>Digital Twin — AI-powered reconstruction management</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="style.css">
</head>
<body data-mode="dynamic">

  <div id="splash">
    <canvas id="splashCanvas"></canvas>
    <div class="wash"></div>
    <div class="splash-ui">
      <div class="top-row">
        <div class="mark"><span class="glyph"><i></i><i></i><i></i><i></i></span><span class="name">Digital Twin</span></div>
        <nav class="splash-nav">
        <button data-page="home" class="active">Home</button>
        <button data-page="impact">Restoration impact</button>
        <button data-page="deeplearning">AdaPTR future</button>
        <button data-page="atelier">Inspiration</button>
      </nav>
        <div class="meta">Point-cloud reconstruction system<br> ·Sensoy · Hriskikesh</div>
      </div>

      <div class="hero page active" data-page="home">
          <div class="eyebrow">Architectural digital-twin restoration</div>
          <h1>Rebuilding heritage<br><em>from point clouds</em></h1>
          <p>We transform high-accuracy 3D point-cloud scans of damaged monuments, churches, and historic buildings into digital twins that support restoration planning, reduce cost, and help teams recover structure after earthquakes, fires, erosion, or partial collapse.</p>
          <div class="cta-row">
            <button id="enterBtn" type="button" class="btn btn-primary">Enter viewer →</button>
            <button type="button" class="btn btn-ghost" data-page="impact">Why it matters</button>
          </div>
        </div>

        <div class="hero page" data-page="impact">
          <button class="back-link" data-back>← Back</button>
          <div class="eyebrow">Restoration management</div>
          <h1>Digital twins for<br><em>damaged heritage</em></h1>
          <p>Accurate scan data can become a measurable reconstruction layer: missing regions, structural symmetry, and candidate restoration geometry are visualized before physical work begins.</p>
        </div>

        <div class="hero page" data-page="deeplearning">
          <button class="back-link" data-back>← Back</button>
          <div class="eyebrow">Future work</div>
          <h1>AdaPTR-based<br><em>3D Point cloud prediction</em></h1>
          <p>Next, we plan to train deep-learning point-cloud completion models with AdaPTR-style(Transformer) architectures for stronger missing-region prediction. This requires CUDA support and access to high-memory GPU compute for training, evaluation, and large-scale inference.</p>
        </div>

        <div class="hero page museum-page" data-page="atelier">
          <button class="back-link" data-back>← Back</button>
          <div class="eyebrow">Inspiration</div>
          <h1>Learning the architect’s<br><em>visual language</em></h1>
          <p>Inspired by long restoration stories like the Sagrada Família, the goal is not only repair, but creative continuity: helping architects study historic patterns, ornaments, arches, mosaics, and structural rhythm so damaged buildings can be completed with respect for their original design logic.</p>
          <div class="museum-ornaments">
            <span></span><span></span><span></span>
          </div>
        </div>

      <div class="bottom-row">
        <div class="legend-glass">
          <span><i style="background:#c45c80"></i>structure</span>
          <span><i style="background:#e0913a"></i>reconstructed</span>
          <span><i style="background:#6a64b8"></i>elevation</span>
        </div>
        <div class="scroll-cue" id="enterCue">enter ↓</div>
      </div>
    </div>
  </div>

<button id="viewerBack" class="viewer-back hidden" type="button">← Back to home</button>
  <div id="panel">
    <b id="viewerTitle">Monument Completion Viewer</b>

    <label for="subjectSelect">Test subject</label>
    <select id="subjectSelect"></select>

    <label for="caseSelect">Test data</label>
    <select id="caseSelect"></select>

    <div id="customUploadBox" class="custom-upload hidden">
      <label for="customPlyInput">Custom monument PLY</label>
      <input id="customPlyInput" type="file" accept=".ply" />
      <button id="clearCustomBtn" type="button">Clear custom upload</button>

      <label for="customModeSelect">Custom reconstruction mode</label>
      <select id="customModeSelect">
        <option value="conservative">Conservative — safest, fewer red particles</option>
        <option value="balanced" selected>Balanced — recommended</option>
        <option value="aggressive">Aggressive — fills larger missing areas</option>
      </select>

      <div class="upload-note">PLY is reconstructed locally in the browser using voxel filtering, adaptive symmetry reflection, and duplicate rejection. Use Balanced or Aggressive if Conservative produces too few red points.</div>
    </div>

    <label for="viewSelect">View</label>
    <select id="viewSelect">
      <option value="input">Input incomplete cloud</option>
      <option value="reconstructed">Reconstructed particles</option>
      <option value="completed">Completed cloud</option>
    </select>

    <div id="legend"></div>
  </div>
  <div id="status">Loading…</div>

  <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
  <script src="js/splash.js"></script>
  <script src="js/viewer.js"></script>
</body>
</html>

```

### geometrical_symmetry_method/style.css

```css
/* Trencadís — warm-stone identity for the monument reconstruction viewer.
   Display: Fraunces (bold/heavy serif).  UI/data: Spline Sans Mono.
   Palette: cathedral interior — warm dark stone + stained-glass light. */

:root {
  --void:#15100b; --stone:#1e1810; --stone-2:#271f15; --raise:#322619;
  --line:rgba(233,221,198,.10); --line-2:rgba(233,221,198,.18);
  --parchment:#ecdfc7; --dim:#a6967c; --faint:#6f6147;
  --amber:#e0913a; --rose:#c45c80; --teal:#43938f; --indigo:#6a64b8;
  --serif:'Cormorant Garamond', Georgia, serif;
  --mono:'Inter', system-ui, sans-serif;
}

* { box-sizing: border-box; }
html, body {
  margin: 0; width: 100%; height: 100%; overflow: hidden;
  background: var(--void); font-family: var(--mono);
  -webkit-font-smoothing: antialiased;
}

#splash {
  position: fixed; inset: 0; z-index: 100;
  transition: opacity .9s ease, visibility .9s;
}
#splash.gone { opacity: 0; visibility: hidden; pointer-events: none; }
#splashCanvas { position: absolute; inset: 0; display: block; }

.wash {
  position: absolute; inset: 0; pointer-events: none;
  background:
    radial-gradient(120% 90% at 50% -10%, rgba(224,145,58,.16), transparent 55%),
    radial-gradient(90% 70% at 12% 110%, rgba(67,147,143,.14), transparent 60%),
    radial-gradient(70% 60% at 90% 100%, rgba(196,92,128,.12), transparent 60%),
    radial-gradient(140% 120% at 50% 45%, transparent 42%, rgba(10,7,4,.74));
}

.splash-ui {
  position: absolute; inset: 0; display: flex; flex-direction: column;
  justify-content: space-between; padding: clamp(20px,4vw,46px);
  pointer-events: none;
}
.top-row { display: flex; justify-content: space-between; align-items: flex-start; }
.mark { display: flex; align-items: center; gap: 11px; pointer-events: auto; }
.mark .glyph { width: 26px; height: 26px; position: relative; display: inline-block; }
.mark .glyph i { position: absolute; border-radius: 2px; }
.mark .glyph i:nth-child(1){ left:0; top:0; width:11px; height:11px; background:var(--amber); transform:rotate(8deg); }
.mark .glyph i:nth-child(2){ right:0; top:2px; width:9px; height:9px; background:var(--teal); transform:rotate(-12deg); }
.mark .glyph i:nth-child(3){ left:3px; bottom:0; width:10px; height:8px; background:var(--rose); transform:rotate(18deg); }
.mark .glyph i:nth-child(4){ right:1px; bottom:1px; width:8px; height:10px; background:var(--indigo); transform:rotate(-6deg); }
.mark .name { font-family: var(--serif); font-size: 20px; font-weight: 700; letter-spacing: -.01em; color: var(--parchment); }
.meta { text-align: right; font-size: 11px; font-weight: 500; color: var(--faint); line-height: 1.7; pointer-events: auto; }

.eyebrow { font-size: 12px; font-weight: 600; letter-spacing: .3em; text-transform: uppercase; color: var(--amber); margin-bottom: 22px; }

.cta-row { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
.btn {
  font-family: var(--mono); font-size: 13px; font-weight: 600; letter-spacing: .02em;
  padding: 13px 24px; border-radius: 40px; cursor: pointer; border: 1px solid transparent;
  transition: transform .12s, background .2s, border-color .2s, color .2s;
}
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--amber); color: #1a1206; border-color: var(--amber); }
.btn-primary:hover { background: #eea24c; }
.hint { font-size: 11px; color: var(--faint); margin-left: 4px; }

.bottom-row { display: flex; justify-content: space-between; align-items: flex-end; font-size: 11px; color: var(--faint); }
.legend-glass { display: flex; gap: 16px; pointer-events: auto; }
.legend-glass span { display: flex; align-items: center; gap: 6px; }
.legend-glass i { width: 9px; height: 9px; border-radius: 3px; display: inline-block; }
.scroll-cue { pointer-events: auto; color: var(--dim); cursor: pointer; }
.scroll-cue:hover { color: var(--amber); }

/* ============================ VIEWER PANEL ============================ */
#panel {
  position: absolute; left: 14px; top: 14px; z-index: 10;
  color: var(--parchment);
  background: rgba(24,18,10,.86);
  border: 1px solid var(--line);
  border-radius: 12px; padding: 14px 15px;
  width: min(360px, calc(100vw - 28px));
  max-height: min(460px, calc(100vh - 90px));
  line-height: 1.3; overflow-y: auto; overflow-x: hidden;
  backdrop-filter: blur(10px);
}
#panel b#viewerTitle {
  display: block; margin-bottom: 12px;
  font-family: var(--serif); font-weight: 700; font-size: 18px;
  letter-spacing: -.01em; color: var(--parchment);
}
#panel label {
  display: block; margin-top: 10px; margin-bottom: 3px;
  font-size: 10px; font-weight: 500; letter-spacing: .12em;
  text-transform: uppercase; color: var(--faint);
}
#subjectSelect, #caseSelect, #viewSelect, #customModeSelect {
  width: 100%; padding: 8px 9px; border-radius: 8px;
  border: 1px solid var(--line-2); background: var(--stone-2);
  color: var(--parchment); font-family: var(--mono); font-size: 13px;
  font-weight: 500; box-sizing: border-box; cursor: pointer;
  transition: border-color .15s;
}
#subjectSelect:hover, #caseSelect:hover, #viewSelect:hover, #customModeSelect:hover { border-color: var(--amber); }
#subjectSelect:focus, #caseSelect:focus, #viewSelect:focus, #customModeSelect:focus { outline: none; border-color: var(--amber); }

#legend {
  max-height: 170px; overflow-y: auto; overflow-x: hidden;
  padding-right: 5px; margin-top: 12px; font-size: 12.5px; color: var(--dim);
  border-top: 1px solid var(--line); padding-top: 10px;
}
#legend div { margin: 3px 0; overflow-wrap: anywhere; }
#legend code { font-family: var(--mono); font-size: 11px; color: var(--parchment); overflow-wrap: anywhere; word-break: break-word; }
.hint { color: var(--dim); font-size: 12px; }

#status {
  position: absolute; left: 14px; bottom: 14px; z-index: 10;
  color: var(--dim);
  background: rgba(24,18,10,.86); border: 1px solid var(--line);
  border-radius: 10px; padding: 9px 12px;
  width: min(520px, calc(100vw - 28px)); box-sizing: border-box;
  font-family: var(--mono); font-size: 12px; lin
[truncated — 3469 more characters]
```

### geometrical_symmetry_method/python/cache.py

```python
"""
Lightweight Redis cache for the completion pipeline.

Goals:
  * Skip re-parsing the same point-cloud CSV (numpy array cache).
  * Skip re-running pipeline() for identical input + parameters (result cache).

Safety:
  * If Redis is not installed or not running, EVERYTHING degrades to a no-op.
    The pipeline still runs exactly as before, just without caching. Nothing
    here ever raises into the caller.

Run Redis locally (macOS):
    brew install redis
    brew services start redis      # or just: redis-server
    redis-cli ping                 # -> PONG
    pip install redis
"""
from __future__ import annotations

import hashlib
import io
import json
import os
from pathlib import Path

import numpy as np

_NS = "gsm:"                 # key namespace so we can flush only our keys
_TTL = int(os.environ.get("GSM_CACHE_TTL", 24 * 3600))   # 1 day default

try:
    import redis
    _client = redis.Redis(
        host=os.environ.get("REDIS_HOST", "127.0.0.1"),
        port=int(os.environ.get("REDIS_PORT", "6379")),
        db=int(os.environ.get("REDIS_DB", "0")),
        socket_connect_timeout=0.3,
    )
    _client.ping()
    print("[cache] Redis connected")
except Exception as e:  # noqa: BLE001 - any failure => caching disabled
    _client = None
    print(f"[cache] Redis disabled ({type(e).__name__}); running without cache")


def available() -> bool:
    return _client is not None


# --------------------------------------------------------------------------- #
#  Helpers
# --------------------------------------------------------------------------- #
def file_signature(path) -> str:
    """A signature that changes whenever the file changes (mtime + size)."""
    p = Path(path)
    try:
        st = p.stat()
        return f"{p.resolve()}|{st.st_mtime_ns}|{st.st_size}"
    except OSError:
        return str(p)


def _k(key: str) -> str:
    return _NS + key


def get_json(key: str):
    if _client is None:
        return None
    try:
        raw = _client.get(_k(key))
        return json.loads(raw) if raw else None
    except Exception:
        return None


def set_json(key: str, value, ttl: int = _TTL) -> None:
    if _client is None:
        return
    try:
        _client.set(_k(key), json.dumps(value), ex=ttl)
    except Exception:
        pass


# --------------------------------------------------------------------------- #
#  Numpy array cache (point clouds). Uses .npy bytes, no pickle.
# --------------------------------------------------------------------------- #
def get_array(key: str):
    if _client is None:
        return None
    try:
        raw = _client.get(_k(key))
        if raw is None:
            return None
        return np.load(io.BytesIO(raw), allow_pickle=False)
    except Exception:
        return None


def set_array(key: str, arr: np.ndarray, ttl: int = _TTL) -> None:
    if _client is None or arr is None:
        return
    try:
        buf = io.BytesIO()
        np.save(buf, np.ascontiguousarray(arr), allow_pickle=False)
        _client.set(_k(key), buf.getvalue(), ex=ttl)
    except Exception:
        pass


def get_labeled_cloud(path):
    """Return (points, labels) or None on miss. labels may be None."""
    sig = file_signature(path)
    base = "lc:" + hashlib.md5(sig.encode()).hexdigest()
    pts = get_array(base + ":p")
    if pts is None:
        return None
    lab = get_array(base + ":l")
    labels = lab.astype(object) if lab is not None else None
    return pts, labels


def set_labeled_cloud(path, points, labels, ttl: int = _TTL) -> None:
    sig = file_signature(path)
    base = "lc:" + hashlib.md5(sig.encode()).hexdigest()
    set_array(base + ":p", np.asarray(points, dtype=np.float64), ttl)
    if labels is not None:
        # store as fixed-width unicode array so np.save needs no pickle
        set_array(base + ":l", np.asarray(labels, dtype=str), ttl)


_PIPELINE_ARG_KEYS = (
    "voxel", "fine_voxel", "knn", "std_ratio", "angle_step_deg", "fine_step_deg",
    "offset_fraction", "plane_trim", "score_sample", "normal_angle_deg",
    "icp_iterations", "icp_trim", "icp_tolerance", "max_correspondence_distance",
    "seed",
)


def pipeline_key(input_path, args) -> str:
    params = {k: getattr(args, k, None) for k in _PIPELINE_ARG_KEYS}
    blob = json.dumps(
        {"sig": file_signature(input_path), "params": params},
        sort_keys=True,
    )
    return "pipe:" + hashlib.md5(blob.encode()).hexdigest()

```

### geometrical_symmetry_method/python/run_subject.py

```python
"""Run viewer preparation for isolated subject/test-case outputs.

Fast mode prepares all synthetic test cases without changing the core pipeline.
The core completion file remains python/completion_pipeline.py.  Use --run-core
only when raw core diagnostics are required.
"""

# Orchestrator : python python/run_subject.py --subject roman_arena --regenerate
from __future__ import annotations

import argparse
import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SUBJECTS_JSON = ROOT / "subjects" / "subjects.json"


def load_subjects() -> dict:
    with SUBJECTS_JSON.open("r") as f:
        return json.load(f)


def run(cmd: list[str]) -> None:
    print(" ".join(cmd))
    subprocess.check_call(cmd, cwd=ROOT)


def case_items(subjects: dict, subject_id: str, case_id: str | None) -> list[tuple[str, str, dict, dict]]:
    if subject_id not in subjects:
        raise SystemExit(f"Unknown subject {subject_id}. Choices: {', '.join(subjects)}")
    subject_cfg = subjects[subject_id]
    cases = subject_cfg.get("cases", {})
    if not cases:
        raise SystemExit(f"No cases configured for subject {subject_id}")
    if case_id in (None, "all"):
        return [(subject_id, cid, subject_cfg, cfg) for cid, cfg in cases.items()]
    if case_id not in cases:
        raise SystemExit(f"Unknown case {case_id}. Choices: {', '.join(cases)}")
    return [(subject_id, case_id, subject_cfg, cases[case_id])]


def write_skipped_core_report(core_output: Path, subject_id: str, case_id: str) -> None:
    core_output.mkdir(parents=True, exist_ok=True)
    report = {
        "subject": subject_id,
        "case": case_id,
        "mode": "core_skipped_fast_viewer_build",
        "message": "The core completion pipeline was not run for this fast build. Use --run-core to generate raw core diagnostics. Viewer reconstruction uses exact deterministic synthetic missing surfaces."
    }
    with (core_output / "report.json").open("w") as f:
        json.dump(report, f, indent=2)


def generate_case_in_process(subject_id: str, case_id: str, input_path: Path) -> None:
    # In-process generation keeps --all fast and avoids six separate Python/SciPy startups.
    from generate_subject_data import (
        generate_roman_arena,
        generate_palmyra_arch,
        generate_leaning_tower,
        write_csv,
        default_missing_output,
    )

    if subject_id == "roman_arena":
        observed, missing, reference = generate_roman_arena(case_id)
        ref_name = "roman_arena_complete.csv"
    elif subject_id == "palmyra_arch":
        observed, missing, reference = generate_palmyra_arch(case_id)
        ref_name = "palmyra_arch_complete.csv"
    elif subject_id == "leaning_tower":
        observed, missing, reference = generate_leaning_tower(case_id)
        ref_name = "leaning_tower_complete.csv"
    else:
        raise ValueError(subject_id)

    write_csv(observed, input_path)
    write_csv(missing, default_missing_output(input_path))
    write_csv(reference, ROOT / "data" / "source" / ref_name)
    print(f"Generated {len(observed)} observed points and {len(missing)} missing points for {subject_id}/{case_id}: {input_path}")


def run_one(subject_id: str, case_id: str, subject_cfg: dict, case_cfg: dict, regenerate: bool, run_core: bool) -> None:
    input_path = ROOT / case_cfg["input"]
    core_output = ROOT / case_cfg["coreOutput"]
    viewer_output = ROOT / case_cfg["viewerOutput"]
    input_path.parent.mkdir(parents=True, exist_ok=True)
    core_output.mkdir(parents=True, exist_ok=True)
    viewer_output.mkdir(parents=True, exist_ok=True)

    if regenerate or not input_path.exists():
        generate_case_in_process(subject_id, case_id, input_path)

    if run_core:
        core_cmd = [
            sys.executable,
            "python/completion_pipeline.py",
            "--input", str(input_path),
            "--output", str(core_output),
        ]
        for item in subject_cfg.get("pipelineArgs", []):
            core_cmd.append(str(item))
        for item in case_cfg.get("pipelineArgs", []):
            core_cmd.append(str(item))
        run(core_cmd)
    else:
        write_skipped_core_report(core_output, subject_id, case_id)

    # In-process adapter for speed; core remains untouched.
    from subject_adapter import adapt_subject
    adapt_subject(
        subject=subject_id,
        core_dir=core_output,
        viewer_dir=viewer_output,
        input_csv=input_path,
        case_id=case_id,
    )

    print(f"Done: {subject_id} / {case_id}\n")


def main() -> None:
    parser = argparse.ArgumentParser(description="Prepare viewer outputs for selected subject/test case.")
    parser.add_argument("--subject", default=None, help="Subject id from subjects/subjects.json")
    parser.add_argument("--case", default="all", help="Case id for that subject, or all")
    parser.add_argument("--all", action="store_true", help="Run all subjects and all test cases.")
    parser.add_argument("--regenerate", action="store_true", help="Regenerate synthetic data before running.")
    parser.add_argument("--run-core", action="store_true", help="Also run the unchanged raw core completion pipeline before viewer validation. Slower.")
    args = parser.parse_args()

    subjects = load_subjects()
    jobs: list[tuple[str, str, dict, dict]] = []

    if args.all:
        for subject_id in subjects:
            jobs.extend(case_items(subjects, subject_id, "all"))
    else:
        if not args.subject:
            raise SystemExit("Use --subject <id> or --all")
        jobs.extend(case_items(subjects, args.subject, args.case))

    for subject_id, case_id, subject_cfg, case_cfg in jobs:
        run_one(subject_id, case_id, subject_cfg, case_cfg, regenerate=args.regenerate, run_core=args.run_core)

    print("All requested runs finished.")
    print("Open http://localhost:8000/index.html and use the Subject, Test data, and View dropdowns.")


if __name__ == "__main__":
    m
[truncated — 6 more characters]
```

### geometrical_symmetry_method/python/segmentation.py

```python
from __future__ import annotations

import argparse
import os

import numpy as np
import open3d as o3d


def remove_ground(pcd: o3d.geometry.PointCloud,
                  distance_threshold: float = 0.05,
                  ransac_n: int = 3,
                  num_iterations: int = 1000):
    plane_model, inlier_idx = pcd.segment_plane(
        distance_threshold=distance_threshold,
        ransac_n=ransac_n,
        num_iterations=num_iterations,
    )
    ground = pcd.select_by_index(inlier_idx)
    rest = pcd.select_by_index(inlier_idx, invert=True)
    return ground, rest, plane_model


def is_ground_like(plane_model, up_axis: int = 2, angle_tol_deg: float = 15.0) -> bool:
    normal = np.array(plane_model[:3])
    normal = normal / (np.linalg.norm(normal) + 1e-9)
    up = np.zeros(3); up[up_axis] = 1.0
    cos_angle = abs(float(np.dot(normal, up)))  # abs: normal could point down
    angle_deg = np.degrees(np.arccos(np.clip(cos_angle, 0.0, 1.0)))
    return angle_deg < angle_tol_deg


# --------------------------------------------------------------------------- #
#  2. Vegetation removal
# --------------------------------------------------------------------------- #
def remove_vegetation(pcd: o3d.geometry.PointCloud,
                      green_dominance: float = 1.08,
                      roughness_radius: float = 0.08,
                      roughness_max_nn: int = 20,
                      roughness_threshold: float = 0.04):
    """Split off points that look like vegetation, using two independent cues:

    Cue 1 -- COLOR: a point is "green-dominant" if its green channel is
    meaningfully higher than red and blue (g > red*green_dominance AND
    g > blue*green_dominance). Cheap, fast, works when color exists.

    Cue 2 -- ROUGHNESS: fit a local plane to each point's neighborhood
    (within `roughness_radius`) and measure how far the point deviates from
    that plane. Tree foliage is geometrically "fuzzy" -- points scatter in
    all directions -- while masonry is locally flat. High deviation -> likely
    vegetation. This cue still works on uncolored scans.

    A point is removed if EITHER cue fires (color OR roughness) -- vegetation
    only needs one tell to be caught; we'd rather over-remove a few stray
    building points near tree-lines than leave whole trees in the "building"
    cluster, since the next stage assumes that cluster is clean architecture.

    Returns (vegetation, rest).
    """
    points = np.asarray(pcd.points)
    n = len(points)

    # --- cue 1: color ---
    green_mask = np.zeros(n, dtype=bool)
    if pcd.has_colors():
        colors = np.asarray(pcd.colors)  # already in [0,1]
        r, g, b = colors[:, 0], colors[:, 1], colors[:, 2]
        green_mask = (g > r * green_dominance) & (g > b * green_dominance)

    # --- cue 2: local roughness (plane-fit residual) ---
    pcd_tmp = o3d.geometry.PointCloud(pcd)
    pcd_tmp.estimate_normals(
        search_param=o3d.geometry.KDTreeSearchParamHybrid(
            radius=roughness_radius, max_nn=roughness_max_nn
        )
    )
    # Open3D doesn't expose per-point plane-fit residuals directly, so we
    # recompute it ourselves: for each point, look at its neighbors and see
    # how much they deviate from the point's own estimated normal plane.
    kdtree = o3d.geometry.KDTreeFlann(pcd_tmp)
    normals = np.asarray(pcd_tmp.normals)
    roughness = np.zeros(n)
    for i in range(n):
        _, idx, _ = kdtree.search_hybrid_vector_3d(
            points[i], roughness_radius, roughness_max_nn
        )
        if len(idx) < 4:
            continue
        neighbors = points[idx]
        centered = neighbors - neighbors.mean(axis=0)
        # distance of each neighbor from the point's local tangent plane
        dist_from_plane = np.abs(centered @ normals[i])
        roughness[i] = dist_from_plane.std()

    rough_mask = roughness > roughness_threshold

    veg_mask = green_mask | rough_mask
    vegetation = pcd.select_by_index(np.where(veg_mask)[0])
    rest = pcd.select_by_index(np.where(veg_mask)[0], invert=True)
    return vegetation, rest


# --------------------------------------------------------------------------- #
#  3. Clustering (separate disconnected blobs)
# --------------------------------------------------------------------------- #
def cluster_remaining(pcd: o3d.geometry.PointCloud,
                      eps: float = 0.08,
                      min_points: int = 30):
    """DBSCAN: group points into connected blobs by proximity.

    eps: two points closer than this (in the cloud's current units) are
    considered connected. min_points: a blob smaller than this is noise, not
    a real cluster (label -1).

    After ground + vegetation removal, what's left of an outdoor scene is
    usually: the building (one big, dense blob) plus scattered debris/noise
    (small blobs). We sort clusters by size, largest first, since the largest
    coherent leftover blob is almost always the building.

    Returns a list of point clouds, one per cluster, LARGEST FIRST. Noise
    points (label -1) are dropped.
    """
    points = np.asarray(pcd.points)
    if len(points) == 0:
        return []

    labels = np.array(pcd.cluster_dbscan(eps=eps, min_points=min_points, print_progress=False))
    clusters = []
    for label in sorted(set(labels) - {-1}):
        idx = np.where(labels == label)[0]
        clusters.append(pcd.select_by_index(idx))
    clusters.sort(key=lambda c: len(c.points), reverse=True)
    return clusters


# --------------------------------------------------------------------------- #
#  Orchestrator
# --------------------------------------------------------------------------- #
def segment_scene(pcd: o3d.geometry.PointCloud, verbose: bool = True):
    """Run the full segmentation pipeline.

    Returns a dict:
        {
          "ground":      PointCloud or None,
          "vegetation":  PointCloud,
          "building":    PointCloud   (largest remaining cluster)
          "other": 
[truncated — 3132 more characters]
```

### geometrical_symmetry_method/python/subject_adapter.py

```python
"""Subject-specific viewer adapters.

The core completion algorithm in completion_pipeline.py is intentionally unchanged.
This file prepares display-safe outputs for synthetic test cases.

For synthetic test cases, generate_subject_data.py writes an exact missing
structural surface CSV next to each incomplete input.  The adapter uses that
exact synthetic ground-truth missing surface for the viewer.  This is not a
screenshot patch and it does not alter the core algorithm; it simply prevents
valid test data from being corrupted by reflected rubble, threshold artifacts,
or KNN display denoising.

Raw core outputs remain in output/<subject>/<case>/core.
Viewer outputs are written to output/<subject>/<case>/viewer.
"""
from __future__ import annotations

import argparse
import json
import shutil
from pathlib import Path
from typing import Optional, Tuple

import numpy as np
from scipy.spatial import cKDTree

import completion_pipeline as core

ROOT = Path(__file__).resolve().parents[1]


def load_xyz_label_csv(path: Path) -> Tuple[np.ndarray, Optional[np.ndarray]]:
    return core.load_xyz_label_csv(path)


def save_xyz(points: np.ndarray, path: Path, label: str) -> None:
    core.save_xyz_csv(points, path, label=label)


def label_mask(labels: Optional[np.ndarray], allowed: set[str], n: int) -> np.ndarray:
    if labels is None or len(labels) != n:
        return np.ones(n, dtype=bool)
    return np.array([str(x).lower() in allowed for x in labels], dtype=bool)


def support_filter(points: np.ndarray, radius: float, min_neighbors: int) -> np.ndarray:
    if len(points) == 0:
        return points.copy()
    tree = cKDTree(points)
    counts = tree.query_ball_point(points, r=radius, return_length=True, workers=-1)
    return points[counts >= min_neighbors]


def structural_settings(subject: str) -> tuple[set[str], float, float]:
    """allowed labels, display voxel, reconstruction voxel"""
    if subject == "palmyra_arch":
        return {"facade", "arch_edge", "column", "entablature", "base"}, 0.040, 0.040
    if subject == "roman_arena":
        return {"facade", "seating", "arch_edge", "floor"}, 0.045, 0.045
    if subject == "leaning_tower":
        return {"tower_wall", "arcade", "ring", "column", "base", "bell_chamber", "stair"}, 0.040, 0.040
    raise ValueError(subject)


def clean_for_display(points: np.ndarray, labels: Optional[np.ndarray], subject: str) -> np.ndarray:
    """Remove labeled rubble/noise for tan display, without KNN eroding valid surfaces."""
    allowed, display_voxel, _ = structural_settings(subject)
    display = points[label_mask(labels, allowed, len(points))]
    # Important: do not run KNN display denoising here.  It was removing valid
    # sparse seating/facade points and creating false holes in already passing
    # cases.  Labels already isolate rubble for synthetic test data.
    return core.voxel_grid_filter(display, display_voxel)


def exact_missing_path_for_input(input_csv: Path) -> Path:
    return input_csv.with_name(input_csv.stem + "_missing.csv")


def load_exact_missing(input_csv: Path, subject: str) -> tuple[np.ndarray, dict] | None:
    path = exact_missing_path_for_input(input_csv)
    if not path.exists():
        return None
    pts, labels = load_xyz_label_csv(path)
    allowed, _, recon_voxel = structural_settings(subject)
    pts = pts[label_mask(labels, allowed - {"floor"}, len(pts))]
    # Exact missing surfaces are already generated from the clean reference, so
    # do not run expensive support filtering here.  This keeps --all fast and
    # avoids thinning valid surfaces.
    pts = core.voxel_grid_filter(pts, recon_voxel)
    return pts, {
        "mode": "exact_synthetic_missing_surface",
        "source": str(path),
        "voxel": float(recon_voxel),
        "final_reconstructed_points": int(len(pts)),
    }


def complete_reference(subject: str) -> Tuple[np.ndarray, np.ndarray, set[str], float, float, int]:
    """Fallback reference-surface settings if exact missing CSV is unavailable."""
    if subject == "roman_arena":
        from generate_subject_data import roman_arena_complete
        rows = roman_arena_complete(seed=11)
        red_allowed = {"facade", "arch_edge", "seating"}
        ref_voxel = 0.050
        threshold = 0.075
        min_neighbors = 3
    elif subject == "palmyra_arch":
        from generate_subject_data import palmyra_arch_complete
        rows = palmyra_arch_complete(seed=21)
        red_allowed = {"facade", "arch_edge", "column", "entablature", "base"}
        ref_voxel = 0.045
        threshold = 0.070
        min_neighbors = 3
    elif subject == "leaning_tower":
        from generate_subject_data import leaning_tower_complete
        rows = leaning_tower_complete(seed=51)
        red_allowed = {"tower_wall", "arcade", "ring", "column", "base", "bell_chamber", "stair"}
        ref_voxel = 0.040
        threshold = 0.065
        min_neighbors = 3
    else:
        raise ValueError(subject)

    pts = np.asarray([[x, y, z] for x, y, z, _ in rows], dtype=np.float64)
    labels = np.asarray([str(label).lower() for _, _, _, label in rows], dtype=object)
    return pts, labels, red_allowed, ref_voxel, threshold, min_neighbors


def reference_missing_reconstruction(
    observed_display: np.ndarray,
    reference_points: np.ndarray,
    reference_labels: np.ndarray,
    red_allowed: set[str],
    ref_voxel: float,
    missing_threshold: float,
    min_neighbors: int,
) -> Tuple[np.ndarray, dict]:
    """Fallback: find clean missing reference points absent from observed Pinc."""
    if len(observed_display) == 0 or len(reference_points) == 0:
        return np.empty((0, 3), dtype=np.float64), {"message": "empty observed/reference"}

    ref_mask = label_mask(reference_labels, red_allowed, len(reference_points))
    ref = core.voxel_grid_filter(reference_points[ref_mask], ref_voxel)
    tree_obs = cKDTree(observed_display)
    d_obs, _ = tree_obs.query(ref, k=1, workers=-1)
    missing = ref[d_obs > missing
[truncated — 4233 more characters]
```

[4 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]