mirror of
https://github.com/thunderheronight/splat-workshop.git
synced 2026-10-18 12:01:54 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9260dd42 | ||
|
|
ec1f119e37 | ||
|
|
e84f0836b3 | ||
|
|
6f46a2b94a | ||
|
|
d01571fadd | ||
|
|
24513b7483 | ||
|
|
a4ef32f28a | ||
|
|
cc6c96b242 | ||
|
|
08f049c7be | ||
|
|
376d197f47 | ||
|
|
e0f8e92c5d | ||
|
|
20b1262fa9 |
No files matched your search
@@ -2,6 +2,39 @@
|
||||
|
||||
All notable changes to Splat Workshop. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.5.0] – 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- **Mesh export**: *Mesh …* on every scene and tour turns the splats into a surface with Spirula Studio (`spirula mesh`) – for Blender and other 3D programs, or for 3D printing. Formats: **GLB** (recommended: colours or texture in one file), **STL** (no colours, for slicers), **OBJ** (with a texture if possible, otherwise without colours) and **PLY** (colours per corner).
|
||||
- **Stands upright, floor at height 0**: our scenes have Y pointing down like the Steam Frame's tracking, so a mesh used to lie on its side in Blender. Now PLY and STL get Z up, GLB and OBJ Y up as their standard and custom want (Blender turns those into Z up itself on import – checked in Blender 5.2 for every format); the floor – found as for the floor plan – is moved to height 0. In metres.
|
||||
- What lies well outside the room is left out before meshing (as the *Edges* clean-up does) – otherwise big blobs beyond the walls become lumps floating around the room.
|
||||
- A **texture** from the training images when they are still there; after *clean up → training images*, colours per corner from the splats.
|
||||
- The mesh lives in the scene's folder (`mesh/`); a new one replaces the old one. *Mesh ✓* shows which scenes have one.
|
||||
- Also on the command line: `scripts/mesh.sh` and `scripts/upright.py`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A versions job with one version said "1 versions".
|
||||
|
||||
## [0.4.2] – 2026-10-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Unfolding recordings or versions sometimes did nothing**: every click loaded the whole scene list again, which took about 3 seconds while the Frame was switched off – clicking again in that time folded it back. Now it opens and closes at once, and an expired listing of the Frame is renewed in the background instead of holding up the scene list.
|
||||
- **"To the Frame" on a version** could be clicked while the Frame was not reachable; now it is greyed out like the one on the scene.
|
||||
|
||||
## [0.4.1] – 2026-10-15
|
||||
|
||||
Setup made clearer – thanks to the first two issues from outside (#1, #2).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A typo in the config is pointed out**: a setting Splat Workshop does not know – such as `splat-transform =` instead of `splat_transform =` – used to be ignored silently, so splat-transform stayed "not found" without a reason. Now the setup hints name it, with the right spelling. `install.sh` does too.
|
||||
- **splat-transform on the shell's PATH only**: `install.sh` checked the PATH of the shell it runs in, but Splat Workshop, started from the desktop or the tray, does not read `.bashrc`/`.zshrc` – the installer said ✓, the app said "not found". Now `install.sh` writes the full path into a new config, or prints the line to add to an existing one; the hint in the app explains why and how.
|
||||
- **The npm instructions** in the setup guide installed into a folder whose path did not match the one given for the config. Now: `npm install -g --prefix ~/arcturus/node @playcanvas/splat-transform` (no root needed) and `~/arcturus/node/bin/splat-transform`.
|
||||
- **The Arcturus pipeline and PyAV 19** ("unexpected keyword argument 'metadata_errors'"): Splat Workshop now spots a pipeline that is not pinned to PyAV below 19 before you start, and names the cause and the fix when a conversion fails because of it – instead of only a traceback. It does not change the pipeline itself.
|
||||
|
||||
## [0.4.0] – 2026-10-15
|
||||
|
||||
### Added
|
||||
@@ -138,6 +171,9 @@ First release.
|
||||
- **Floor plans** (experimental) in two styles with a measuring tool.
|
||||
- Configurable video player; English and German interface.
|
||||
|
||||
[0.5.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.4.2...v0.5.0
|
||||
[0.4.2]: https://github.com/thunderheronight/splat-workshop/compare/v0.4.1...v0.4.2
|
||||
[0.4.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.3.1...v0.4.0
|
||||
[0.3.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.3...v0.3.0
|
||||
|
||||
@@ -30,4 +30,5 @@ one in place while a job uses it – write a new file and `mv` it over.
|
||||
## File systems
|
||||
|
||||
Runs may live on NTFS: no folder or file name may end with a dot or contain `:`; git there needs
|
||||
`core.fileMode false`.
|
||||
`core.fileMode false`. With that, git does not see the executable bit: a new shell script needs
|
||||
`git update-index --chmod=+x scripts/<name>.sh`, or it is not executable in CI and fresh clones.
|
||||
@@ -22,12 +22,22 @@ Arcturus Vision stellt die Rekonstruktions-Skripte und einen Spirula-Studio-Fork
|
||||
4. **In die Galerie**: Vorschaubild und Namensschild im Stil der Beispielszenen, kopiert in den Ordner für eigene Szenen von Gaussian XR auf der Frame (`Documents/GaussianXR`).
|
||||
5. **Die Wohnung zusammensetzen**: Räume gruppieren – ein Raum darf aus mehreren Aufnahmen bestehen – und zu einem Rundgang machen, durch den du gehst, voll für den PC, kleiner für die Frame.
|
||||
|
||||
## Neu in 0.5
|
||||
|
||||
- 🧊 **Mesh-Export** – *Mesh …* macht aus einer Szene oder einem Rundgang eine Oberfläche für Blender und andere 3D-Programme (GLB, OBJ, PLY) oder für den 3D-Druck (STL). Sie steht aufrecht mit dem Boden auf Höhe 0, statt auf der Seite zu liegen – für jedes Format per Import in Blender geprüft; mit Textur, wenn die Trainingsbilder noch da sind. (Angeregt in [#2](https://github.com/thunderheronight/splat-workshop/issues/2).)
|
||||
|
||||
<img src="docs/mesh.png" width="400" alt="Der Mesh-Dialog: GLB und STL angehakt, OBJ und PLY zur Wahl, Textur aus den Trainingsbildern, das vorhandene Mesh mit seinen Dateien"> <img src="docs/mesh-blender.png" width="500" alt="Das Mesh eines Beispielraums in Blender: aufrecht auf dem Boden">
|
||||
<br><sub>Beispieldaten – ein künstlicher Raum, in Blender gerendert (englische Oberfläche).</sub>
|
||||
|
||||
## Neu in 0.4
|
||||
|
||||
- 🧽 **Fassungen bereinigen** *(experimentell)* – vier Schieberegler im Fassungen-Dialog: **Decke** (eine weiße Decke wird zum dunklen Schleier – der Schleier kommt weg, die Decke wird in ihrer eigenen Farbe geschlossen), **Boden** (Löcher gefüllt), **Dunst** (große durchsichtige Wolken; dünne Gitter und Kabel bleiben), **Rand** (was außerhalb des Raums liegt). Die Szene selbst bleibt; die bereinigte Fassung neben einer ohne vergleichen.
|
||||
- 🎚️ **Fassungen nach Zweck** – *für die Frame* (eine Größe, mit den auf einer Steam Frame gemessenen fps) oder *für den PC* (alle Splats); auf Wunsch dieselbe ohne Bereinigung daneben; vorher eine Vorschau, was entsteht. Fassungen heißen nach ihren Bereinigungsstärken, *Als Vorlage* übernimmt die Einstellungen für den nächsten Versuch, eine PC-Fassung öffnet sich mit einem Klick in **Spirula Studio**, und Fassungen **klappen unter ihrer Szene oder ihrem Rundgang auf**.
|
||||
- 🧭 **Aufgeräumte Knopfleisten** – was du tust, was du anschaust, was du änderst; das Aufklappen in einer eigenen Zeile.
|
||||
|
||||
<img src="docs/versions.png" width="420" alt="Der neue Fassungen-Dialog: für die Frame oder den PC, Größen mit gemessenen fps, vier Bereinigungs-Regler, zusätzlich ohne Bereinigung zum Vergleichen und eine Vorschau der zwei Fassungen"> <img src="docs/versions-list.png" width="520" alt="Eine Szene mit aufgeklappten Fassungen: 1 Mio. für die Frame mit und ohne Bereinigung, alle Splats für den PC mit Spirula und Als Vorlage">
|
||||
<br><sub>Beispieldaten – Szene und Vorschaubild sind Platzhalter (englische Oberfläche).</sub>
|
||||
|
||||
## Neu in 0.3
|
||||
|
||||
- 🚪 **Räume aus mehreren Aufnahmen** – eine Gruppe als *Ein Raum aus mehreren Aufnahmen* markieren, z. B. zwei kurze Runden durchs Wohnzimmer. Beim Zusammensetzen kommt jeder Teil des Raums aus der Aufnahme, die ihn am besten gesehen hat. Die Arcturus-Pipeline nimmt höchstens 500 Bildpaare pro Aufnahme – mehrere kurze Aufnahmen geben einem Raum also deutlich mehr Detail als eine lange.
|
||||
|
||||
@@ -24,12 +24,22 @@ Arcturus Vision publishes the reconstruction scripts and a Spirula Studio fork,
|
||||
4. **Put it in the gallery**: preview image and name plate in the look of the bundled example scenes, copied into Gaussian XR's folder for personal scenes on the Frame (`Documents/GaussianXR`).
|
||||
5. **Put your home together**: group the rooms – a room may be several recordings – and turn them into one tour you walk through, full size for the PC, smaller for the Frame.
|
||||
|
||||
## New in 0.5
|
||||
|
||||
- 🧊 **Mesh export** – *Mesh …* turns a scene or tour into a surface for Blender and other 3D programs (GLB, OBJ, PLY) or for 3D printing (STL). It stands upright with the floor at height 0 instead of lying on its side – checked by importing every format in Blender; with a texture when the training images are still there. (Suggested in [#2](https://github.com/thunderheronight/splat-workshop/issues/2).)
|
||||
|
||||
<img src="docs/mesh.png" width="400" alt="The mesh dialog: GLB and STL ticked, OBJ and PLY to choose, a texture from the training images, the existing mesh with its files"> <img src="docs/mesh-blender.png" width="500" alt="The mesh of an example room in Blender: standing upright on the floor">
|
||||
<br><sub>Example data – a synthetic room, rendered in Blender.</sub>
|
||||
|
||||
## New in 0.4
|
||||
|
||||
- 🧽 **Clean up a version** *(experimental)* – four sliders in the versions dialog: **Ceiling** (a white ceiling turns into a dark veil – the veil goes, the ceiling is closed in its own colour), **Floor** (holes filled), **Haze** (big transparent blobs; thin bars and cables stay), **Edges** (what lies outside the room). The scene itself stays; compare the cleaned version with one without.
|
||||
- 🎚️ **Versions by purpose** – *for the Frame* (one size, with the frame rate measured on a Steam Frame) or *for the PC* (all splats); optionally the same without cleaning up next to it; a preview of what will be made. Versions are named by their clean-up strengths, *As template* takes a version's settings for the next try, a PC version opens in **Spirula Studio** with one click, and versions **unfold under their scene or tour**.
|
||||
- 🧭 **Tidier action bars** – what you do, what you look at, what you change; the unfolding lists on a line of their own.
|
||||
|
||||
<img src="docs/versions.png" width="420" alt="The new version dialog: for the Frame or the PC, sizes with measured frame rates, four clean-up sliders, also without cleaning up to compare, and a preview of the two versions it will make"> <img src="docs/versions-list.png" width="520" alt="A scene with its versions unfolded: 1 M for the Frame with and without cleaning up, all splats for the PC with Spirula and As template">
|
||||
<br><sub>Example data – the scene and its preview are placeholders.</sub>
|
||||
|
||||
## New in 0.3
|
||||
|
||||
- 🚪 **Rooms made of several recordings** – mark a group as *one room made of several recordings*, e.g. two short rounds through the living room. When put together, every part of the room comes from the recording that saw it best. The Arcturus pipeline uses at most 500 image pairs per recording, so several short recordings give a room far more detail than one long one.
|
||||
|
||||
@@ -88,7 +88,15 @@ Wandelt das Ergebnis in das kompakte SOG-Format, das Gaussian XR lädt:
|
||||
npm install -g @playcanvas/splat-transform
|
||||
```
|
||||
|
||||
Ohne globale Installation: `npm install --prefix ~/arcturus/node @playcanvas/splat-transform` und in der Config `splat_transform = "~/arcturus/node/bin/splat-transform"` eintragen.
|
||||
Scheitert `npm install -g` an fehlenden Rechten, ins eigene Home installieren – ohne root:
|
||||
|
||||
```bash
|
||||
npm install -g --prefix ~/arcturus/node @playcanvas/splat-transform
|
||||
```
|
||||
|
||||
und in der Config unter `[paths]` `splat_transform = "~/arcturus/node/bin/splat-transform"` eintragen (mit Unterstrich – `splat-transform =` ist keine Einstellung und wird ignoriert; die Splat-Werkstatt weist darauf hin).
|
||||
|
||||
**Immer den vollen Pfad in die Config schreiben**, wenn splat-transform nur im PATH deiner Shell steht (`.bashrc`, `.zshrc`): Vom Desktop oder Tray gestartet liest die Splat-Werkstatt diese Dateien nicht und findet es sonst nicht. `install.sh` trägt die Zeile ein oder gibt sie aus.
|
||||
|
||||
## 5. Der Splat-Werkstatt sagen, wo alles liegt
|
||||
|
||||
@@ -106,7 +114,7 @@ Splat-Werkstatt neu starten (Tray → Beenden, dann wieder starten). Fehlt noch
|
||||
|
||||
| Meldung / Problem | Ursache / Lösung |
|
||||
|---|---|
|
||||
| `av.open() got an unexpected keyword argument 'metadata_errors'` | PyAV 19 hat diese Option entfernt. Ältere Version festlegen: im `# /// script`-Kopf oben in `av1_to_3dgs.py` und `av1_colmap.py` `"av>=18.1"` in `"av>=18.1,<19"` ändern. (Mit der Pipeline vom Herbst 2026 nötig; neuere Versionen haben es evtl. behoben.) |
|
||||
| `av.open() got an unexpected keyword argument 'metadata_errors'` | PyAV 19 hat diese Option entfernt. **Behoben in der Pipeline ab Oktober 2026 (`av1-3dgs-pipeline-faad5aac4.zip`)** – sie kann PyAV 18.1 und 19; einfach die aktuelle herunterladen (danke an [@andzejsp](https://github.com/andzejsp) für den Hinweis, #2). Nur bei einer älteren Pipeline die ältere Version festlegen: im `# /// script`-Kopf oben in `av1_to_3dgs.py` und `av1_colmap.py` `"av>=18.1"` in `"av>=18.1,<19"` ändern. (Mit der Pipeline vom Herbst 2026 nötig; neuere Versionen haben es evtl. behoben.) Die Splat-Werkstatt (ab 0.4.1) weist schon vor dem Start in ihren Einrichtungshinweisen darauf hin und nennt den Grund, falls eine Umwandlung daran scheitert. |
|
||||
| `Could not select unique evenly spaced capture instants` | Aufnahme zu kurz. Die Pipeline braucht mindestens 100 Aufnahmezeitpunkte – mindestens 10 Sekunden aufnehmen, besser 30+. |
|
||||
| Spirula findet keine GPU / nutzt llvmpipe | Vulkan-Treiber fehlt, siehe Schritt 1. |
|
||||
| Die Szene taucht in Gaussian XR nicht auf | Sie muss als echter Ordner (kein Symlink) mit den Galeriebildern in `Scenes/` von Gaussian XR liegen – das macht der Knopf *Auf die Frame*. Ein Steam-Update oder „Dateien überprüfen“ von Gaussian XR kann den Ordner zurücksetzen; dann die Szene einfach neu übertragen. |
|
||||
|
||||
+10
-2
@@ -88,7 +88,15 @@ Converts the result into the compact SOG format that Gaussian XR loads:
|
||||
npm install -g @playcanvas/splat-transform
|
||||
```
|
||||
|
||||
If you can't or don't want to install globally: `npm install --prefix ~/arcturus/node @playcanvas/splat-transform` and set `splat_transform = "~/arcturus/node/bin/splat-transform"` in the config.
|
||||
If `npm install -g` fails with a permission error, install it into your home instead – no root needed:
|
||||
|
||||
```bash
|
||||
npm install -g --prefix ~/arcturus/node @playcanvas/splat-transform
|
||||
```
|
||||
|
||||
and set `splat_transform = "~/arcturus/node/bin/splat-transform"` under `[paths]` in the config (with an underscore – `splat-transform =` is not a setting and is ignored; Splat Workshop points that out).
|
||||
|
||||
**Always set the full path in the config** if splat-transform is only on the PATH of your shell (`.bashrc`, `.zshrc`): started from the desktop or the tray, Splat Workshop does not read those files and would not find it. `install.sh` writes or prints the line for you.
|
||||
|
||||
## 5. Tell Splat Workshop where everything is
|
||||
|
||||
@@ -106,7 +114,7 @@ Restart Splat Workshop (tray → Quit, then start it again). If something is sti
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
|---|---|
|
||||
| `av.open() got an unexpected keyword argument 'metadata_errors'` | PyAV 19 removed that option. Pin the older version: in the `# /// script` header at the top of `av1_to_3dgs.py` and `av1_colmap.py`, change `"av>=18.1"` to `"av>=18.1,<19"`. (Was needed with the pipeline from autumn 2026; newer versions may have fixed it.) |
|
||||
| `av.open() got an unexpected keyword argument 'metadata_errors'` | PyAV 19 removed that option. **Fixed in the pipeline from October 2026 on (`av1-3dgs-pipeline-faad5aac4.zip`)** – it supports PyAV 18.1 and 19; just download the current one (thanks to [@andzejsp](https://github.com/andzejsp) for spotting it, #2). Only with an older pipeline: pin the older version: in the `# /// script` header at the top of `av1_to_3dgs.py` and `av1_colmap.py`, change `"av>=18.1"` to `"av>=18.1,<19"`. (Was needed with the pipeline from autumn 2026; newer versions may have fixed it.) Splat Workshop (0.4.1 and newer) points this out in its setup hints before you start, and names it if a conversion fails because of it. |
|
||||
| `Could not select unique evenly spaced capture instants` | Recording too short. The pipeline needs at least 100 capture instants – record at least 10 seconds, better 30+. |
|
||||
| Spirula finds no GPU / uses llvmpipe | Vulkan driver missing, see step 1. |
|
||||
| The scene does not appear in Gaussian XR | It must be a real folder in Gaussian XR's `Scenes/` (no symlink) with the gallery images – Splat Workshop's *To the Frame* button does this for you. A Steam update or “verify files” of Gaussian XR can reset that folder; just copy the scene again. |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 286 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
+24
-3
@@ -26,19 +26,40 @@ for tool in ffmpeg ffprobe uv ssh curl; do
|
||||
command -v "$tool" >/dev/null && echo " ✓ $tool" || { echo " ✗ $tool"; missing=1; }
|
||||
done
|
||||
command -v splat-transform >/dev/null && echo " ✓ splat-transform" ||
|
||||
echo " ! splat-transform not on PATH (npm install -g @playcanvas/splat-transform, or set it in the config)"
|
||||
echo " ! splat-transform not on PATH (npm install -g --prefix ~/.local @playcanvas/splat-transform needs no root; or set its path in the config)"
|
||||
python3 -c 'import PySide6' 2>/dev/null && echo " ✓ PySide6 (tray icon)" ||
|
||||
echo " · PySide6 not installed – uv will fetch it on first start for the tray icon"
|
||||
[ $missing = 0 ] || echo "Install the missing tools first (see README)."
|
||||
|
||||
mkdir -p "$CONF" "$APPS" "$ICONS"
|
||||
created=0
|
||||
if [ ! -f "$CONF/config.toml" ]; then
|
||||
cp "$ROOT/config.example.toml" "$CONF/config.toml"
|
||||
echo "Created $CONF/config.toml – set the paths to the Arcturus pipeline and Spirula, and your Frame's address."
|
||||
created=1
|
||||
echo "Created $CONF/config.toml from config.example.toml – set the paths to the Arcturus pipeline and Spirula, and your Frame's address."
|
||||
else
|
||||
echo "Keeping existing $CONF/config.toml"
|
||||
echo "Keeping existing $CONF/config.toml (all settings with explanations: $ROOT/config.example.toml)"
|
||||
fi
|
||||
|
||||
# Started from the desktop, Splat Workshop does not see a PATH set in .bashrc/.zshrc: give it the full path
|
||||
st=$(command -v splat-transform || true)
|
||||
conf_st=$(python3 -c 'import sys, tomllib
|
||||
print(tomllib.load(open(sys.argv[1], "rb")).get("paths", {}).get("splat_transform", ""))' "$CONF/config.toml" 2>/dev/null || true)
|
||||
if [ -n "$st" ] && [ -z "$conf_st" ]; then
|
||||
if [ $created = 1 ]; then
|
||||
sed -i "s|^splat_transform = \"\"|splat_transform = \"$st\"|" "$CONF/config.toml"
|
||||
echo " ✓ splat_transform = \"$st\" written to the config"
|
||||
else
|
||||
echo " ! Add this line under [paths] in $CONF/config.toml – started from the desktop,"
|
||||
echo " Splat Workshop does not see the PATH of your shell:"
|
||||
echo " splat_transform = \"$st\""
|
||||
fi
|
||||
fi
|
||||
# Settings Splat Workshop does not know (typos such as "splat-transform" instead of "splat_transform")
|
||||
(cd "$ROOT" && python3 -c 'from splat_workshop import config
|
||||
for key, hint in config.load().unknown:
|
||||
print(f" ! Unknown setting \"{key}\" in the config" + (f" – did you mean \"{hint}\"?" if hint else " – it is ignored"))') || true
|
||||
|
||||
cp "$ROOT/web/icon.svg" "$ICONS/splat-workshop.svg"
|
||||
entry() {
|
||||
cat <<EOF
|
||||
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/bin/bash
|
||||
# A mesh (surface) of a finished scene or tour, for 3D programs and 3D printing – made by Spirula Studio
|
||||
# (`spirula mesh`) from the splats inside the room (scripts/clean.py --outside), then stood upright with
|
||||
# the floor at height 0 (scripts/upright.py).
|
||||
#
|
||||
# Usage: scripts/mesh.sh <run folder> <formats> [texture]
|
||||
# <formats> comma-separated: glb, ply, obj, stl
|
||||
# glb colours (texture or per corner); Y up as glTF wants it – Blender, Windows 3D viewer, web
|
||||
# ply colours per corner; Z up – MeshLab, CloudCompare, Blender
|
||||
# obj with a texture, or without colours; Y up as is custom for OBJ – nearly every 3D program
|
||||
# stl no colours; Z up – slicers for 3D printing
|
||||
# texture 1: a texture from the training images (only if they are still there, see "clean up"),
|
||||
# otherwise colours per corner from the splats
|
||||
# Writes <run folder>/mesh/ (replaced as a whole when done). Lines starting with "@@" are progress markers.
|
||||
set -euo pipefail
|
||||
# Everything in one block: bash reads it completely before it starts, so updating this file
|
||||
# (git pull, editing) does not affect a run that is already going.
|
||||
{
|
||||
|
||||
HERE=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
|
||||
ROOT=$(dirname "$HERE")
|
||||
eval "$(cd "$ROOT" && python3 -m splat_workshop.config --shell)"
|
||||
|
||||
out=$(realpath "${1:?Give a run folder}")
|
||||
formats=${2:?Give the formats, e.g. glb,stl}
|
||||
texture=${3:-0}
|
||||
src=$out/spirula/scene.ply
|
||||
[ -f "$src" ] || { echo "No spirula/scene.ply in $out" >&2; exit 1; }
|
||||
[ -x "$SPIRULA_BIN" ] || { echo "Spirula Studio not found: $SPIRULA_BIN" >&2; exit 1; }
|
||||
IFS=',' read -ra want <<< "$formats"
|
||||
for f in "${want[@]}"; do
|
||||
case "$f" in glb|ply|obj|stl) ;; *) echo "Unknown mesh format: $f (glb, ply, obj, stl)" >&2; exit 1 ;; esac
|
||||
done
|
||||
slug=$(basename "$out")
|
||||
stem=$(printf '%s' "$slug" | tr 'A-Z' 'a-z')
|
||||
|
||||
# Colours: a texture needs the cameras and the training images; without them colours per corner
|
||||
data=(--no-data)
|
||||
if [ "$texture" = 1 ]; then
|
||||
if [ -d "$out/dataset/images" ] && [ -d "$out/dataset/sparse" ]; then
|
||||
data=(--data "$out/dataset")
|
||||
else
|
||||
echo "No training images any more – colours per corner instead of a texture"
|
||||
texture=0
|
||||
fi
|
||||
fi
|
||||
colour_of() { # the colour each format gets
|
||||
case "$1" in
|
||||
glb) [ "$texture" = 1 ] && echo texture || echo vertex ;;
|
||||
ply) echo vertex ;;
|
||||
obj) [ "$texture" = 1 ] && echo texture || echo none ;;
|
||||
stl) echo none ;;
|
||||
esac
|
||||
}
|
||||
colours=$(for f in "${want[@]}"; do colour_of "$f"; done | sort -u | paste -sd,)
|
||||
|
||||
tmp=$out/mesh.tmp
|
||||
rm -rf "$tmp"
|
||||
mkdir -p "$tmp"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
echo "@@ vstep 1 2 mesh"
|
||||
# what lies well outside the room (big blobs explaining the view through a window or beyond the walls)
|
||||
# would become lumps floating around the room: out first, as the "Edges" clean-up does
|
||||
from=$src
|
||||
if uv run -q "$HERE/clean.py" "$src" "$tmp/inside.ply" --outside 1 >/dev/null 2>&1; then
|
||||
from=$tmp/inside.ply
|
||||
else
|
||||
echo "No room found (walls) – meshing everything"
|
||||
fi
|
||||
echo "Meshing $slug ($formats; colours: $colours) ..."
|
||||
"$SPIRULA_BIN" mesh "$from" "${data[@]}" --format "$formats" --color "$colours" --output "$tmp/$stem"
|
||||
rm -f "$tmp/inside.ply"
|
||||
|
||||
# Spirula names the files <stem>.<ext>, or <stem>_<colour>.<ext> when it made several colours:
|
||||
# keep the one colour every format was meant to have, under <stem>.<ext>
|
||||
echo "@@ vstep 2 2 upright"
|
||||
keep=()
|
||||
for f in "${want[@]}"; do
|
||||
c=$(colour_of "$f")
|
||||
case "$c" in vertex) tag=vertexcolor ;; none) tag=nocolor ;; texture) tag=texture ;; esac
|
||||
file=""
|
||||
for cand in "$tmp/${stem}_${tag}.$f" "$tmp/${stem}_${tag}"*".$f" "$tmp/$stem.$f"; do
|
||||
[ -f "$cand" ] && { file=$cand; break; }
|
||||
done
|
||||
[ -n "$file" ] || { echo "Spirula made no $f file" >&2; exit 1; }
|
||||
if [ "$c" != texture ] && [ "$file" != "$tmp/$stem.$f" ]; then
|
||||
mv "$file" "$tmp/$stem.$f" # (a texture's file names are referenced inside: they stay)
|
||||
file=$tmp/$stem.$f
|
||||
fi
|
||||
keep+=("$file")
|
||||
done
|
||||
for f in "$tmp"/*; do # the other colour pairs Spirula made along the way
|
||||
case " ${keep[*]} " in *" $f "*) continue ;; esac
|
||||
case "$f" in *.mtl|*.png|*.jpg|*.jpeg) continue ;; esac
|
||||
rm -f "$f"
|
||||
done
|
||||
# upright, the floor (found as for the floor plan) at height 0: the room stands on the ground plane
|
||||
uv run -q --no-project --with numpy --with pillow python3 "$HERE/upright.py" --floor-from "$src" "${keep[@]}"
|
||||
|
||||
python3 - "$tmp/mesh.json" "$formats" "$texture" <<'PY'
|
||||
import json, sys, time
|
||||
path, formats, texture = sys.argv[1:]
|
||||
json.dump({"formats": formats.split(","), "texture": texture == "1", "created": time.time()}, open(path, "w"), indent=1)
|
||||
PY
|
||||
rm -rf "$out/mesh"
|
||||
mv "$tmp" "$out/mesh"
|
||||
trap - EXIT
|
||||
echo "@@ done local"
|
||||
echo "Done: $out/mesh"
|
||||
exit
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Stand a mesh upright: our scenes have Y pointing down (like the Steam Frame's tracking). 3D programs
|
||||
expect Z up for PLY and STL (Blender, slicers for 3D printing) and Y up for glTF/GLB (by its standard) and
|
||||
OBJ (by custom) – Blender turns those two into Z up itself on import. Without this a room lies on its side
|
||||
or upside down. (Checked by importing each format in Blender 5.2.)
|
||||
|
||||
Usage: python3 upright.py [--floor Y] <file> [<file> ...] (.ply .obj .stl .glb .gltf; changed in place)
|
||||
|
||||
PLY, STL: positions and normals turned so that up (-Y) becomes +Z: (x, y, z) -> (x, z, -y)
|
||||
OBJ: turned 180° about X, up (-Y) becomes +Y: (x, y, z) -> (x, -y, -z)
|
||||
GLB/glTF: the scene's root nodes go under one new node turned 180° about X: (x, y, z) -> (x, -y, -z)
|
||||
– nothing else in the file is touched (textures, materials, accessors stay as they are)
|
||||
--floor Y where the floor is in the scene (Y down): it is moved to height 0, so the room stands on
|
||||
the ground plane (the scene's origin is where the Frame was at the start, at eye height)
|
||||
--floor-from S the same, found in the scene's splats S (spirula/scene.ply) as for the floor plan
|
||||
"""
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
Z_UP = np.array([[1, 0, 0], [0, 0, 1], [0, -1, 0]], dtype=np.float64) # proper rotation, det +1
|
||||
Y_UP_QUAT = [1.0, 0.0, 0.0, 0.0] # x, y, z, w: 180° about X
|
||||
LIFT = 0.0 # the floor's height before (Y down)
|
||||
|
||||
|
||||
def _replace(path: Path, data: bytes):
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def ply(path: Path):
|
||||
raw = path.read_bytes()
|
||||
end = raw.index(b"end_header\n") + len(b"end_header\n")
|
||||
header = raw[:end].decode("ascii", "replace").splitlines()
|
||||
if "format binary_little_endian 1.0" not in header:
|
||||
sys.exit(f"{path}: only binary little-endian PLY is supported")
|
||||
sizes = {"float": "<f4", "double": "<f8", "uchar": "u1", "char": "i1", "ushort": "<u2", "short": "<i2",
|
||||
"uint": "<u4", "int": "<i4"}
|
||||
props, count, first = [], 0, True
|
||||
for line in header:
|
||||
parts = line.split()
|
||||
if parts[:2] == ["element", "vertex"]:
|
||||
count = int(parts[2])
|
||||
elif parts[:1] == ["element"]:
|
||||
first = False # properties after this belong to faces etc.
|
||||
elif parts[:1] == ["property"] and first and count:
|
||||
if parts[1] == "list":
|
||||
sys.exit(f"{path}: list properties on vertices are not supported")
|
||||
props.append((parts[2], sizes[parts[1]]))
|
||||
dtype = np.dtype(props)
|
||||
v = np.frombuffer(raw, dtype=dtype, count=count, offset=end).copy()
|
||||
for a, b, c in (("x", "y", "z"), ("nx", "ny", "nz")):
|
||||
if a in dtype.names:
|
||||
xyz = np.stack([v[a], v[b], v[c]], 1).astype(np.float64) @ Z_UP.T
|
||||
if a == "x":
|
||||
xyz[:, 2] += LIFT
|
||||
v[a], v[b], v[c] = xyz.T
|
||||
_replace(path, raw[:end] + v.tobytes() + raw[end + v.nbytes:])
|
||||
|
||||
|
||||
def obj(path: Path):
|
||||
out = []
|
||||
with open(path, "rb") as f:
|
||||
for line in f:
|
||||
if line.startswith((b"v ", b"vn ")):
|
||||
tag, rest = line.split(None, 1)
|
||||
nums = rest.split()
|
||||
x, y, z = (float(n) for n in nums[:3])
|
||||
x, y, z = x, -y, -z # Y up (180° about X)
|
||||
y += LIFT if tag == b"v" else 0.0
|
||||
line = b"%s %.7g %.7g %.7g%s\n" % (tag, x, y, z, (b" " + b" ".join(nums[3:])) if nums[3:] else b"")
|
||||
out.append(line)
|
||||
_replace(path, b"".join(out))
|
||||
|
||||
|
||||
def stl(path: Path):
|
||||
raw = path.read_bytes()
|
||||
if raw[:5] == b"solid" and b"facet" in raw[:512]:
|
||||
sys.exit(f"{path}: only binary STL is supported")
|
||||
n, = struct.unpack_from("<I", raw, 80)
|
||||
tri = np.frombuffer(raw, dtype=np.dtype([("n", "<f4", 3), ("v", "<f4", (3, 3)), ("attr", "<u2")]),
|
||||
count=n, offset=84).copy()
|
||||
tri["n"] = tri["n"] @ Z_UP.T
|
||||
tri["v"] = tri["v"] @ Z_UP.T
|
||||
tri["v"][..., 2] += LIFT
|
||||
_replace(path, raw[:84] + tri.tobytes())
|
||||
|
||||
|
||||
def _wrap(gltf: dict):
|
||||
for scene in gltf.get("scenes", []):
|
||||
root = {"name": "upright (Y up)", "rotation": Y_UP_QUAT, "translation": [0.0, LIFT, 0.0],
|
||||
"children": scene.get("nodes", [])}
|
||||
gltf.setdefault("nodes", []).append(root)
|
||||
scene["nodes"] = [len(gltf["nodes"]) - 1]
|
||||
|
||||
|
||||
def glb(path: Path):
|
||||
raw = path.read_bytes()
|
||||
magic, version, _ = struct.unpack_from("<4sII", raw)
|
||||
if magic != b"glTF" or version != 2:
|
||||
sys.exit(f"{path}: not a glTF 2 binary")
|
||||
jlen, jtype = struct.unpack_from("<I4s", raw, 12)
|
||||
if jtype != b"JSON":
|
||||
sys.exit(f"{path}: first chunk is not JSON")
|
||||
gltf = json.loads(raw[20:20 + jlen])
|
||||
_wrap(gltf)
|
||||
js = json.dumps(gltf, separators=(",", ":")).encode()
|
||||
js += b" " * (-len(js) % 4) # chunks are 4-byte aligned, JSON padded with spaces
|
||||
rest = raw[20 + jlen:]
|
||||
total = 12 + 8 + len(js) + len(rest)
|
||||
_replace(path, struct.pack("<4sII", b"glTF", 2, total) + struct.pack("<I4s", len(js), b"JSON") + js + rest)
|
||||
|
||||
|
||||
def gltf(path: Path):
|
||||
data = json.loads(path.read_text())
|
||||
_wrap(data)
|
||||
_replace(path, json.dumps(data).encode())
|
||||
|
||||
|
||||
def main():
|
||||
global LIFT
|
||||
args = sys.argv[1:]
|
||||
if args[:1] == ["--floor"]:
|
||||
LIFT, args = float(args[1]), args[2:]
|
||||
elif args[:1] == ["--floor-from"]:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import floorplan
|
||||
xyz, _ = floorplan.load_ply(Path(args[1]), np.random.default_rng(0))
|
||||
LIFT, args = (float(floorplan.heights(xyz[:, 1])[0]) if len(xyz) else 0.0), args[2:]
|
||||
print(f"floor: {LIFT:.2f}")
|
||||
for arg in args:
|
||||
p = Path(arg)
|
||||
kind = p.suffix.lower().lstrip(".")
|
||||
fn = {"ply": ply, "obj": obj, "stl": stl, "glb": glb, "gltf": gltf}.get(kind)
|
||||
if fn is None:
|
||||
sys.exit(f"{p}: unknown mesh format")
|
||||
fn(p)
|
||||
print(f"upright: {p.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -5,7 +5,9 @@ Order: built-in defaults < ~/.config/splat-workshop/config.toml (or $SPLAT_WORKS
|
||||
|
||||
`python3 -m splat_workshop.config --shell` prints the values as shell assignments for scripts/.
|
||||
"""
|
||||
import difflib
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
@@ -32,9 +34,47 @@ DEFAULTS = {
|
||||
}
|
||||
|
||||
|
||||
def unknown_keys(data: dict) -> list[tuple[str, str]]:
|
||||
"""Settings in the config file that Splat Workshop does not know (e.g. a typo: "splat-transform"),
|
||||
as ("section.key", suggestion or "")."""
|
||||
out = []
|
||||
for section, values in data.items():
|
||||
if section not in DEFAULTS:
|
||||
close = difflib.get_close_matches(section, DEFAULTS, n=1, cutoff=0.6)
|
||||
out.append((section, close[0] if close else ""))
|
||||
continue
|
||||
if not isinstance(values, dict):
|
||||
continue
|
||||
for key in values:
|
||||
if key not in DEFAULTS[section]:
|
||||
close = difflib.get_close_matches(key.replace("-", "_"), DEFAULTS[section], n=1, cutoff=0.6)
|
||||
out.append((f"{section}.{key}", f"{section}.{close[0]}" if close else ""))
|
||||
return out
|
||||
|
||||
|
||||
# PyAV 19 removed av.open(metadata_errors=...), which the Arcturus pipeline (autumn 2026) uses
|
||||
PYAV_FILES = ("av1_to_3dgs.py", "av1_colmap.py")
|
||||
RX_AV_DEP = re.compile(r'^#\s*"av(?P<spec>[^"]*)"', re.M)
|
||||
|
||||
|
||||
def pyav_unpinned(pipeline: Path) -> list[str]:
|
||||
"""Pipeline scripts that use metadata_errors but allow PyAV 19 or newer."""
|
||||
out = []
|
||||
for name in PYAV_FILES:
|
||||
try:
|
||||
text = (pipeline / name).read_text(errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
m = RX_AV_DEP.search(text)
|
||||
if "metadata_errors" in text and m and "<19" not in m["spec"].replace(" ", ""):
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self, data: dict, source: Path | None):
|
||||
def __init__(self, data: dict, source: Path | None, unknown=()):
|
||||
self.source = source
|
||||
self.unknown = list(unknown)
|
||||
base = source.parent if source else Path.home()
|
||||
|
||||
def path(v: str) -> Path:
|
||||
@@ -63,12 +103,17 @@ class Config:
|
||||
def problems(self) -> list[tuple[str, dict]]:
|
||||
"""Missing prerequisites as (message key, params) for the setup hints in the UI."""
|
||||
out = []
|
||||
for key, suggestion in self.unknown:
|
||||
out.append(("setup.unknown_key" if suggestion else "setup.unknown_key.plain",
|
||||
{"key": key, "suggestion": suggestion, "file": str(self.source)}))
|
||||
if not (self.pipeline / "av1_to_3dgs.py").is_file():
|
||||
out.append(("setup.pipeline", {"path": str(self.pipeline)}))
|
||||
elif bad := pyav_unpinned(self.pipeline):
|
||||
out.append(("setup.pyav", {"files": ", ".join(bad), "path": str(self.pipeline)}))
|
||||
if not os.access(self.spirula, os.X_OK):
|
||||
out.append(("setup.spirula", {"path": str(self.spirula)}))
|
||||
if not self.splat_transform:
|
||||
out.append(("setup.splat_transform", {}))
|
||||
out.append(("setup.splat_transform", {"file": str(self.source or CONFIG_FILE)}))
|
||||
for tool in ("ffmpeg", "ffprobe", "uv"):
|
||||
if not shutil.which(tool):
|
||||
out.append(("setup.tool", {"tool": tool}))
|
||||
@@ -103,12 +148,12 @@ def _merge(base: dict, over: dict) -> dict:
|
||||
|
||||
|
||||
def load() -> Config:
|
||||
data, source = DEFAULTS, None
|
||||
data, source, unknown = DEFAULTS, None, []
|
||||
if CONFIG_FILE.is_file():
|
||||
with open(CONFIG_FILE, "rb") as fh:
|
||||
data = _merge(DEFAULTS, tomllib.load(fh))
|
||||
source = CONFIG_FILE
|
||||
return Config(data, source)
|
||||
own = tomllib.load(fh)
|
||||
data, source, unknown = _merge(DEFAULTS, own), CONFIG_FILE, unknown_keys(own)
|
||||
return Config(data, source, unknown)
|
||||
|
||||
|
||||
def shell(cfg: Config) -> str:
|
||||
|
||||
@@ -16,6 +16,7 @@ TEXTS = {
|
||||
"inhibit.busy": "Busy (converting, putting together, copying)",
|
||||
"notify.standby.title": "Everything done", "notify.standby": "The PC goes to standby in {s} seconds – to stop it, untick “Standby when done” in Splat Workshop.",
|
||||
"notify.standby_failed.title": "No standby", "notify.standby_failed": "The PC could not go to standby: {why}",
|
||||
"inhibit.mesh": "Making a mesh of “{name}”", "mesh.step.mesh": "Meshing", "mesh.step.upright": "Standing it upright",
|
||||
"inhibit.versions": "Making versions of “{name}”", "ver.all": "all splats", "ver.size": "{n} M",
|
||||
"ver.clean.ceiling": "ceiling", "ver.clean.floor": "floor", "ver.clean.haze": "haze", "ver.clean.outside": "edges",
|
||||
"notify.failed.title": "Conversion failed",
|
||||
@@ -80,6 +81,7 @@ TEXTS = {
|
||||
"inhibit.busy": "Beschäftigt (Umwandeln, Zusammensetzen, Kopieren)",
|
||||
"notify.standby.title": "Alles fertig", "notify.standby": "Der PC geht in {s} Sekunden in den Standby – zum Abbrechen in der Splat-Werkstatt den Haken bei „Danach in den Standby“ entfernen.",
|
||||
"notify.standby_failed.title": "Kein Standby", "notify.standby_failed": "Der PC konnte nicht in den Standby: {why}",
|
||||
"inhibit.mesh": "Erstellt ein Mesh von „{name}“", "mesh.step.mesh": "Vernetzen", "mesh.step.upright": "Aufrichten",
|
||||
"inhibit.versions": "Erstellt Fassungen von „{name}“", "ver.all": "alle Splats", "ver.size": "{n} Mio.",
|
||||
"ver.clean.ceiling": "Decke", "ver.clean.floor": "Boden", "ver.clean.haze": "Dunst", "ver.clean.outside": "Rand",
|
||||
"notify.failed.title": "Umwandlung fehlgeschlagen",
|
||||
|
||||
+111
-7
@@ -29,7 +29,7 @@ from pathlib import Path
|
||||
from . import check, config
|
||||
from .i18n import T, detect
|
||||
|
||||
VERSION = "0.4.0"
|
||||
VERSION = "0.5.0"
|
||||
CFG = config.load()
|
||||
ROOT = config.ROOT
|
||||
WEB = ROOT / "web"
|
||||
@@ -152,7 +152,7 @@ def pid_alive(pid: int) -> bool:
|
||||
try:
|
||||
cmd = Path(f"/proc/{pid}/cmdline").read_bytes().decode(errors="replace")
|
||||
if cmd:
|
||||
return any(x in cmd for x in ("splat.sh", "combine.sh", "versions.sh"))
|
||||
return any(x in cmd for x in ("splat.sh", "combine.sh", "versions.sh", "mesh.sh"))
|
||||
except OSError:
|
||||
pass
|
||||
if not pid:
|
||||
@@ -176,11 +176,29 @@ def err(code: str, **params) -> dict:
|
||||
# ---------------------------------------------------------------- recordings
|
||||
|
||||
def frame_listing(force=False):
|
||||
"""Recordings on the Frame (cached). None = no Frame configured or not reachable."""
|
||||
"""Recordings on the Frame (cached). None = no Frame configured or not reachable.
|
||||
An expired listing is renewed in the background and the last one answered meanwhile: asking a Frame
|
||||
that is switched off takes seconds, and the scene list (which asks too) would hang that long."""
|
||||
if not FRAME:
|
||||
return None
|
||||
if not force and time.time() - frame_cache["t"] < 20:
|
||||
return frame_cache["data"]
|
||||
if not force and frame_cache["t"]:
|
||||
if not frame_cache.get("renewing"):
|
||||
frame_cache["renewing"] = True
|
||||
threading.Thread(target=renew_frame_listing, daemon=True).start()
|
||||
return frame_cache["data"]
|
||||
return read_frame_listing()
|
||||
|
||||
|
||||
def renew_frame_listing():
|
||||
try:
|
||||
read_frame_listing()
|
||||
finally:
|
||||
frame_cache["renewing"] = False
|
||||
|
||||
|
||||
def read_frame_listing():
|
||||
cmd = (f'(cd {FRAME_VIDEOS} 2>/dev/null && stat -c "%n|%s|%Y" *_stereo.mp4 2>/dev/null); echo ---; '
|
||||
f'ls {FRAME_VIDEOS}/thumbnails 2>/dev/null; echo ---; (ls {FRAME_SCENES}; ls {FRAME_STEAM}) 2>/dev/null')
|
||||
try:
|
||||
@@ -594,9 +612,16 @@ def parse_output(text: str) -> dict:
|
||||
info["done"] = True
|
||||
if d[1] == "frame" and d[2]:
|
||||
info["folder"] = d[2]
|
||||
for needle, hint in KNOWN_FAILURES:
|
||||
if needle in text:
|
||||
info["hint"] = hint
|
||||
return {"phase": phase, "pct": pct, "eta": eta, "info": info}
|
||||
|
||||
|
||||
# Failures with a known cause: a line of the pipeline's output -> a hint in the interface (web/i18n.js)
|
||||
KNOWN_FAILURES = (("unexpected keyword argument 'metadata_errors'", "hint.pyav"),)
|
||||
|
||||
|
||||
def job_status(tail=True) -> dict | None:
|
||||
st = load_state().get("job")
|
||||
if not st:
|
||||
@@ -1279,6 +1304,78 @@ def delete_version(slug: str, key: str):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- meshes (scripts/mesh.sh, 0.5)
|
||||
|
||||
MESH_FORMATS = ("glb", "ply", "obj", "stl")
|
||||
|
||||
|
||||
def mesh_of(slug: str) -> dict:
|
||||
"""The mesh of a scene or tour: its files, and whether a texture is possible (training images still there)."""
|
||||
d = scene_dir(slug) if slug and not slug.startswith(".") else None
|
||||
if not d:
|
||||
return {}
|
||||
texture = (d / "dataset" / "images").is_dir() and (d / "dataset" / "sparse").is_dir()
|
||||
m = d / "mesh"
|
||||
info = {"texture_possible": texture, "files": [], "made": None}
|
||||
if (m / "mesh.json").exists():
|
||||
try:
|
||||
made = json.loads((m / "mesh.json").read_text())
|
||||
except (OSError, ValueError):
|
||||
made = {}
|
||||
src = d / "spirula" / "scene.ply"
|
||||
info["made"] = {**made, "stale": src.exists() and made.get("created", 0) < src.stat().st_mtime}
|
||||
info["files"] = [{"name": f.name, "size": f.stat().st_size} for f in sorted(m.iterdir())
|
||||
if f.is_file() and f.name != "mesh.json"]
|
||||
return info
|
||||
|
||||
|
||||
def start_mesh(p: dict):
|
||||
"""A mesh of a scene or tour in the chosen formats (glb, ply, obj, stl), optionally with a texture."""
|
||||
global proc
|
||||
slug = p.get("slug", "")
|
||||
d = scene_dir(slug) if slug and not slug.startswith(".") else None
|
||||
if not d or not (d / "spirula" / "scene.ply").exists():
|
||||
return err("err.no_scene")
|
||||
formats = [f for f in dict.fromkeys((p.get("formats") or "").split(",")) if f]
|
||||
if not formats or any(f not in MESH_FORMATS for f in formats):
|
||||
return err("err.bad_value", key="formats")
|
||||
texture = bool(p.get("texture")) and mesh_of(slug)["texture_possible"]
|
||||
name = read_meta(slug).get("name") or slug
|
||||
with lock:
|
||||
if (cur := load_state().get("job")) and pid_alive(cur["pid"]):
|
||||
return err("err.job_busy")
|
||||
if scene_busy(slug):
|
||||
return err("err.scene_busy")
|
||||
JOBS.mkdir(parents=True, exist_ok=True)
|
||||
out = JOBS / f"{time.strftime('%Y-%m-%d_%H-%M-%S')}_mesh_{slug}.out"
|
||||
with open(out, "wb") as fh:
|
||||
proc = subprocess.Popen(keep_awake([str(SCRIPTS / "mesh.sh"), str(d), ",".join(formats), "1" if texture else "0"],
|
||||
t("inhibit.mesh", name=name)), cwd=ROOT, stdout=fh,
|
||||
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
|
||||
st = load_state()
|
||||
st["job"] = {"kind": "mesh", "pid": proc.pid, "out": str(out), "name": name, "slug": slug,
|
||||
"recording": "", "quality": "", "upload": False, "started": time.time(), "overrides": {},
|
||||
"steps": [t("mesh.step.mesh"), t("mesh.step.upright")]}
|
||||
save_state(st)
|
||||
return None
|
||||
|
||||
|
||||
def delete_mesh(slug: str):
|
||||
d = scene_dir(slug) if slug and not slug.startswith(".") else None
|
||||
if not d or not (d / "mesh").is_dir():
|
||||
return err("err.no_mesh")
|
||||
if scene_busy(slug):
|
||||
return err("err.scene_busy")
|
||||
try:
|
||||
r = subprocess.run(["gio", "trash", "--", str(d / "mesh")], capture_output=True, text=True, timeout=120)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return err("err.trash_failed", detail=str(e))
|
||||
if r.returncode != 0:
|
||||
return err("err.trash_failed", detail=(r.stderr.strip().splitlines() or [""])[-1])
|
||||
usage_cache.pop(d, None)
|
||||
return None
|
||||
|
||||
|
||||
def start_upload(slug: str, version: str = ""):
|
||||
"""Copy a scene – or one of its versions – to the Frame. Every version gets its own scene folder there."""
|
||||
if not FRAME:
|
||||
@@ -1436,7 +1533,7 @@ def scenes():
|
||||
"ply": (d / "spirula" / "scene.ply").exists(), "on_frame": on_frame, "disk": disk_usage(d),
|
||||
"upload": upload_status(slug), "replacing": replacing,
|
||||
"scale": meta.get("scale") or 1, "scale_xr": bool(meta.get("scale_xr")),
|
||||
"versions": len(list(d.glob("versions/*/version.json")))})
|
||||
"versions": len(list(d.glob("versions/*/version.json"))), "mesh": (d / "mesh" / "mesh.json").exists()})
|
||||
return out
|
||||
|
||||
|
||||
@@ -1995,7 +2092,7 @@ def groups():
|
||||
"rooms": len({r for _, r in inner}),
|
||||
"busy": scene_busy(slug), "options": {k: meta.get(k) for k in ("align", "partition", "frame_splats", "full")},
|
||||
"scale": meta.get("scale") or 1, "scale_xr": bool(meta.get("scale_xr")),
|
||||
"versions": len(list(d.glob("versions/*/version.json"))),
|
||||
"versions": len(list(d.glob("versions/*/version.json"))), "mesh": (d / "mesh" / "mesh.json").exists(),
|
||||
"splats": sum(scene_list[m].get("gaussians") or 0 for m, _ in inner)})
|
||||
return {"groups": out, "suggestion": suggestion(scene_list, out), "mem_available_gb": mem_available()}
|
||||
|
||||
@@ -2285,6 +2382,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(200, data, "image/png", cache=True) if data else self.send(404, b"", "image/png")
|
||||
if u.path == "/api/versions":
|
||||
return self.send(200, {"versions": versions_of(q.get("slug", ""))})
|
||||
if u.path == "/api/mesh":
|
||||
return self.send(200, mesh_of(q.get("slug", "")))
|
||||
if u.path == "/api/floorplan":
|
||||
data = floorplan([x for x in q.get("slugs", "").split(",") if x], q.get("style", "pretty"),
|
||||
q.get("lang", "en"), q.get("title", ""), q.get("group", ""))
|
||||
@@ -2327,7 +2426,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
if not isinstance(p, dict) or any(not isinstance(v, (str, int, float, bool, type(None))) for v in p.values()) \
|
||||
or any(not isinstance(p.get(k, ""), str) for k in ("name", "new", "slug", "path", "what", "quality", "existing", "members", "key", "version", "sizes", "scales", "folder",
|
||||
"names", "place", "where",
|
||||
"align", "partition", "clean")):
|
||||
"align", "partition", "clean", "formats")):
|
||||
return self.send(400, {"error": err("err.bad_request")})
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
e = None
|
||||
@@ -2362,6 +2461,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
remove_from_frame(p.get("slug", ""), p.get("version", "")))
|
||||
elif path == "/api/frame/move":
|
||||
e = move_to_own_folder()
|
||||
elif path == "/api/mesh":
|
||||
e = start_mesh(p)
|
||||
elif path == "/api/mesh/delete":
|
||||
e = delete_mesh(p.get("slug", ""))
|
||||
elif path == "/api/version/delete":
|
||||
e = delete_version(p.get("slug", ""), p.get("version", ""))
|
||||
elif path == "/api/pick":
|
||||
@@ -2428,7 +2531,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
subprocess.Popen([str(CFG.spirula), str(ply)], cwd=ply.parent.parent,
|
||||
start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", str(d)], start_new_session=True,
|
||||
folder = d / "mesh" if p.get("what") == "mesh" and (d / "mesh").is_dir() else d
|
||||
subprocess.Popen(["xdg-open", str(folder)], start_new_session=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
elif path == "/api/shutdown":
|
||||
self.send(200, {"ok": True})
|
||||
|
||||
+22
-1
@@ -86,7 +86,28 @@ png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4
|
||||
(out / (out.name.lower() + ".gallery.png")).write_bytes(png)
|
||||
print("Gaussian XR scene transform: " + str(out / "scene.xrSceneTransform.json"), flush=True)
|
||||
''')
|
||||
script(BIN / "spirula", "#!/bin/sh\nexit 0\n")
|
||||
# spirula: `spirula mesh` writes a tiny room in every format/colour pair the real one writes (tests/fake_mesh.py)
|
||||
script(BIN / "spirula", f'''#!{sys.executable}
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, "{Path(__file__).parent}")
|
||||
with open("{TOOL_LOG}", "a") as log:
|
||||
log.write(json.dumps(["spirula", *sys.argv[1:]]) + "\\n")
|
||||
args = sys.argv[1:]
|
||||
if args[:1] == ["mesh"]:
|
||||
import fake_mesh
|
||||
opt = lambda k: args[args.index(k) + 1]
|
||||
formats, colours, base = opt("--format").split(","), opt("--color").split(","), Path(opt("--output"))
|
||||
ok = {{("glb", "vertex"), ("glb", "none"), ("glb", "texture"), ("ply", "vertex"), ("ply", "none"),
|
||||
("obj", "none"), ("obj", "texture"), ("stl", "none")}}
|
||||
tag = {{"vertex": "_vertexcolor", "none": "_nocolor", "texture": "_texture"}}
|
||||
for f in formats:
|
||||
for c in colours:
|
||||
if (f, c) in ok:
|
||||
fake_mesh.write(base.with_name(base.name + (tag[c] if len(colours) > 1 else "") + "." + f), f, c)
|
||||
if c == "texture" and f == "obj":
|
||||
base.with_name(base.name + (tag[c] if len(colours) > 1 else "") + ".png").write_bytes(b"png")
|
||||
''')
|
||||
# splat-transform: copies the input (a .ply stays a .ply), writes a small file for .sog
|
||||
script(BIN / "splat-transform", f'''#!{sys.executable}
|
||||
import json, shutil, sys
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Writers for the fake `spirula mesh` (tests/conftest.py) and readers for the tests: a tiny room as
|
||||
Spirula would mesh it – in the scene's own frame, Y down: the floor at y = +1.6, the ceiling at y = -0.9,
|
||||
both 4 x 4 m around x, z = 0. Each file holds two quads (four triangles)."""
|
||||
import json
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
|
||||
FLOOR_Y, CEILING_Y = 1.6, -0.9
|
||||
V = np.array([[x, y, z] for y in (FLOOR_Y, CEILING_Y) for x, z in ((-2, -2), (2, -2), (2, 2), (-2, 2))], np.float32)
|
||||
F = np.array([[0, 1, 2], [0, 2, 3], [4, 6, 5], [4, 7, 6]], np.int32)
|
||||
N = np.array([[0, -1, 0]] * 4 + [[0, 1, 0]] * 4, np.float32) # floor faces up (-Y), ceiling down
|
||||
|
||||
|
||||
def write(path, fmt, colour):
|
||||
if fmt == "ply":
|
||||
props = "property float x\nproperty float y\nproperty float z\nproperty float nx\nproperty float ny\nproperty float nz\n"
|
||||
props += "property uchar red\nproperty uchar green\nproperty uchar blue\n" if colour == "vertex" else ""
|
||||
head = f"ply\nformat binary_little_endian 1.0\nelement vertex {len(V)}\n{props}element face {len(F)}\nproperty list uchar int vertex_indices\nend_header\n"
|
||||
body = b""
|
||||
for v, n in zip(V, N):
|
||||
body += struct.pack("<6f", *v, *n) + (bytes([200, 150, 100]) if colour == "vertex" else b"")
|
||||
for f in F:
|
||||
body += struct.pack("<B3i", 3, *f)
|
||||
path.write_bytes(head.encode() + body)
|
||||
elif fmt == "obj":
|
||||
lines = ["# fake"] + [f"v {x} {y} {z}" for x, y, z in V] + [f"vn {x} {y} {z}" for x, y, z in N]
|
||||
lines += [f"f {a + 1} {b + 1} {c + 1}" for a, b, c in F]
|
||||
path.write_text("\n".join(lines) + "\n")
|
||||
elif fmt == "stl":
|
||||
body = b"".join(struct.pack("<12fH", *N[f[0]], *V[f[0]], *V[f[1]], *V[f[2]], 0) for f in F)
|
||||
path.write_bytes(b"\0" * 80 + struct.pack("<I", len(F)) + body)
|
||||
elif fmt == "glb":
|
||||
buf = V.tobytes() + F.astype(np.uint32).tobytes()
|
||||
gltf = {"asset": {"version": "2.0"}, "scene": 0, "scenes": [{"nodes": [0]}], "nodes": [{"mesh": 0, "name": "room"}],
|
||||
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}],
|
||||
"buffers": [{"byteLength": len(buf)}],
|
||||
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": V.nbytes},
|
||||
{"buffer": 0, "byteOffset": V.nbytes, "byteLength": F.size * 4}],
|
||||
"accessors": [{"bufferView": 0, "componentType": 5126, "count": len(V), "type": "VEC3",
|
||||
"min": V.min(0).tolist(), "max": V.max(0).tolist()},
|
||||
{"bufferView": 1, "componentType": 5125, "count": F.size, "type": "SCALAR"}]}
|
||||
js = json.dumps(gltf).encode()
|
||||
js += b" " * (-len(js) % 4)
|
||||
buf += b"\0" * (-len(buf) % 4)
|
||||
path.write_bytes(struct.pack("<4sII", b"glTF", 2, 12 + 8 + len(js) + 8 + len(buf))
|
||||
+ struct.pack("<I4s", len(js), b"JSON") + js + struct.pack("<I4s", len(buf), b"BIN\0") + buf)
|
||||
|
||||
|
||||
def read_points(path):
|
||||
"""Vertex positions as a 3D program sees them (for GLB: with the node transforms applied)."""
|
||||
fmt = path.suffix[1:]
|
||||
raw = path.read_bytes()
|
||||
if fmt == "ply":
|
||||
end = raw.index(b"end_header\n") + len(b"end_header\n")
|
||||
colour = b"property uchar red" in raw[:end]
|
||||
dt = np.dtype([("p", "<f4", 3), ("n", "<f4", 3)] + ([("c", "u1", 3)] if colour else []))
|
||||
return np.frombuffer(raw, dt, count=len(V), offset=end)["p"].astype(float)
|
||||
if fmt == "obj":
|
||||
return np.array([[float(x) for x in l.split()[1:4]] for l in raw.decode().splitlines() if l.startswith("v ")])
|
||||
if fmt == "stl":
|
||||
n, = struct.unpack_from("<I", raw, 80)
|
||||
t = np.frombuffer(raw, np.dtype([("n", "<f4", 3), ("v", "<f4", (3, 3)), ("a", "<u2")]), count=n, offset=84)
|
||||
return t["v"].reshape(-1, 3).astype(float)
|
||||
if fmt == "glb":
|
||||
jl, = struct.unpack_from("<I", raw, 12)
|
||||
gltf = json.loads(raw[20:20 + jl])
|
||||
bin_ = raw[20 + jl + 8:]
|
||||
pts = np.frombuffer(bin_, np.float32, count=len(V) * 3).reshape(-1, 3).astype(float)
|
||||
|
||||
def transform(i, p):
|
||||
node = gltf["nodes"][i]
|
||||
if "rotation" in node:
|
||||
x, y, z, w = node["rotation"]
|
||||
R = np.array([[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
||||
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
||||
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]])
|
||||
p = p @ R.T
|
||||
return p + np.array(node.get("translation", [0, 0, 0]))
|
||||
out = []
|
||||
for root in gltf["scenes"][gltf.get("scene", 0)]["nodes"]:
|
||||
def walk(i, chain):
|
||||
node = gltf["nodes"][i]
|
||||
if "mesh" in node:
|
||||
p = pts
|
||||
for j in reversed(chain + [i]):
|
||||
p = transform(j, p)
|
||||
out.append(p)
|
||||
for c in node.get("children", []):
|
||||
walk(c, chain + [i])
|
||||
walk(root, [])
|
||||
return np.vstack(out)
|
||||
@@ -130,3 +130,18 @@ def test_the_same_folder_in_both_places(api, frame):
|
||||
assert api.post("/api/frame/remove", {"folder": "20_Kitchen", "place": "steam"})[0] == 200
|
||||
assert d.exists() and not (frame / server.FRAME_STEAM / "20_Kitchen").exists()
|
||||
assert api.post("/api/frame/remove", {"folder": "20_Kitchen", "place": "nowhere"})[0] == 400
|
||||
|
||||
|
||||
def test_an_old_frame_listing_is_answered_at_once_and_renewed_behind(frame):
|
||||
"""Asking a Frame that is switched off takes seconds; the scene list must not wait for it every time."""
|
||||
import time as _t
|
||||
first = server.frame_listing()
|
||||
assert first is not None
|
||||
server.frame_cache.update(t=_t.time() - 60, data={"files": [], "thumbs": [], "scenes": ["old"]})
|
||||
start = _t.time()
|
||||
assert server.frame_listing()["scenes"] == ["old"] # the last one, without waiting
|
||||
assert _t.time() - start < 0.5
|
||||
end = _t.time() + 10
|
||||
while server.frame_cache.get("renewing") and _t.time() < end:
|
||||
_t.sleep(0.05)
|
||||
assert server.frame_listing()["scenes"] != ["old"] # renewed in the background
|
||||
@@ -0,0 +1,115 @@
|
||||
"""A mesh of a scene (0.5, scripts/mesh.sh + scripts/upright.py): made by Spirula Studio (faked here, see
|
||||
tests/fake_mesh.py), stood upright with the floor at height 0 – Z up for OBJ/PLY/STL, Y up for GLB, as 3D
|
||||
programs expect – instead of lying on its side as the scene does (Y down, like the Frame's tracking)."""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from conftest import ROOT, RUNS, TRASH, server, tools_called, wait_job
|
||||
import fake_mesh
|
||||
import test_clean
|
||||
|
||||
UPRIGHT = ROOT / "scripts" / "upright.py"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fmt,colour", [("obj", "none"), ("ply", "vertex"), ("ply", "none"), ("stl", "none"), ("glb", "vertex")])
|
||||
def test_upright_puts_the_floor_at_the_bottom(tmp_path, fmt, colour):
|
||||
f = tmp_path / f"room.{fmt}"
|
||||
fake_mesh.write(f, fmt, colour)
|
||||
r = subprocess.run([sys.executable, str(UPRIGHT), "--floor", str(fake_mesh.FLOOR_Y), str(f)], capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stderr
|
||||
p = fake_mesh.read_points(f)
|
||||
up = 1 if fmt in ("glb", "obj") else 2 # glTF and OBJ: Y up; PLY and STL: Z up (as Blender imports them)
|
||||
k = 6 if fmt == "stl" else 4 # STL lists the corners of each triangle: 2 floor triangles
|
||||
floor, ceiling = p[:k, up], p[k:, up]
|
||||
assert np.allclose(floor, 0, atol=1e-5) # the floor at height 0 ...
|
||||
assert np.allclose(ceiling, fake_mesh.FLOOR_Y - fake_mesh.CEILING_Y, atol=1e-5) # ... the ceiling 2.5 m above
|
||||
if fmt in ("obj", "ply"): # a proper turn, not a mirror: x stays
|
||||
assert np.allclose(p[:, 0], fake_mesh.V[:, 0])
|
||||
if fmt == "ply": # normals turn along: the floor faces up
|
||||
raw = f.read_bytes()
|
||||
end = raw.index(b"end_header\n") + len(b"end_header\n")
|
||||
dt = np.dtype([("p", "<f4", 3), ("n", "<f4", 3)] + ([("c", "u1", 3)] if colour == "vertex" else []))
|
||||
n = np.frombuffer(raw, dt, count=8, offset=end)["n"]
|
||||
assert np.allclose(n[:4], [0, 0, 1]) and np.allclose(n[4:], [0, 0, -1])
|
||||
assert raw.endswith(b"".join(np.array([3], np.uint8).tobytes() + fake_mesh.F[i].astype("<i4").tobytes()
|
||||
for i in range(len(fake_mesh.F)))) # faces untouched
|
||||
|
||||
|
||||
def scene(slug="Living", images=False):
|
||||
run = RUNS / slug
|
||||
test_clean.room(run / "spirula" / "scene.ply")
|
||||
(run / "scene.sog").write_bytes(b"sog")
|
||||
if images:
|
||||
(run / "dataset" / "images").mkdir(parents=True)
|
||||
(run / "dataset" / "sparse").mkdir()
|
||||
server.write_meta(slug, name="Living room")
|
||||
return run
|
||||
|
||||
|
||||
def mesh_calls():
|
||||
return [c for c in tools_called() if c[:2] == ["spirula", "mesh"]]
|
||||
|
||||
|
||||
def test_a_mesh_for_blender_and_3d_printing(api):
|
||||
run = scene()
|
||||
assert server.mesh_of("Living") == {"texture_possible": False, "files": [], "made": None}
|
||||
status, r = api.post("/api/mesh", {"slug": "Living", "formats": "glb,stl", "texture": True})
|
||||
assert status == 200, r
|
||||
js = wait_job()
|
||||
assert js["result"] == "ok" and js["kind"] == "mesh", js["tail"]
|
||||
m = server.mesh_of("Living")
|
||||
assert [f["name"] for f in m["files"]] == ["living.glb", "living.stl"] # one file per format, nothing else
|
||||
assert m["made"]["formats"] == ["glb", "stl"] and m["made"]["texture"] is False and not m["made"]["stale"]
|
||||
call = mesh_calls()[0]
|
||||
assert "--no-data" in call # no training images: no texture
|
||||
assert call[call.index("--color") + 1] == "none,vertex" # GLB with colours, STL without
|
||||
assert call[2].endswith("inside.ply") # meshed from inside the room only
|
||||
assert np.allclose(fake_mesh.read_points(run / "mesh" / "living.stl")[:, 2].min(), 0, atol=0.05)
|
||||
assert np.allclose(fake_mesh.read_points(run / "mesh" / "living.glb")[:, 1].min(), 0, atol=0.05)
|
||||
assert not (run / "mesh.tmp").exists()
|
||||
assert [(s["slug"], s["mesh"]) for s in server.scenes()] == [("Living", True)]
|
||||
|
||||
|
||||
def test_a_texture_when_the_training_images_are_there(api):
|
||||
run = scene(images=True)
|
||||
assert server.mesh_of("Living")["texture_possible"]
|
||||
api.post("/api/mesh", {"slug": "Living", "formats": "obj,ply", "texture": True})
|
||||
assert wait_job()["result"] == "ok"
|
||||
call = mesh_calls()[0]
|
||||
assert call[call.index("--data") + 1] == str(run / "dataset")
|
||||
assert call[call.index("--color") + 1] == "texture,vertex"
|
||||
names = [f["name"] for f in server.mesh_of("Living")["files"]]
|
||||
assert names == ["living.ply", "living_texture.obj", "living_texture.png"] # a texture keeps its names
|
||||
|
||||
|
||||
def test_a_new_mesh_replaces_the_old_and_can_be_deleted(api):
|
||||
run = scene()
|
||||
api.post("/api/mesh", {"slug": "Living", "formats": "glb,stl"})
|
||||
wait_job()
|
||||
api.post("/api/mesh", {"slug": "Living", "formats": "obj"})
|
||||
wait_job()
|
||||
assert [f["name"] for f in server.mesh_of("Living")["files"]] == ["living.obj"]
|
||||
assert api.post("/api/mesh/delete", {"slug": "Living"})[0] == 200
|
||||
assert not (run / "mesh").exists() and (TRASH / "mesh").exists()
|
||||
assert api.post("/api/mesh/delete", {"slug": "Living"})[1]["error"]["code"] == "err.no_mesh"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,code", [({"formats": "fbx"}, "err.bad_value"), ({"formats": ""}, "err.bad_value"),
|
||||
({"slug": "Nope", "formats": "glb"}, "err.no_scene"),
|
||||
({"slug": "../x", "formats": "glb"}, "err.no_scene")])
|
||||
def test_odd_meshes_are_refused(api, body, code):
|
||||
scene()
|
||||
status, r = api.post("/api/mesh", {"slug": "Living", **body})
|
||||
assert status == 400 and r["error"]["code"] == code
|
||||
assert not mesh_calls()
|
||||
|
||||
|
||||
def test_mesh_sh_refuses_unknown_formats():
|
||||
run = scene()
|
||||
r = subprocess.run([str(ROOT / "scripts" / "mesh.sh"), str(run), "glb,fbx"], capture_output=True, text=True)
|
||||
assert r.returncode == 1 and "Unknown mesh format: fbx" in r.stderr and not mesh_calls()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Setup hints (0.4.1, from issues #1 and #2): typos in the config, splat-transform only on the shell's PATH,
|
||||
an Arcturus pipeline that PyAV 19 breaks."""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import ROOT, server
|
||||
from splat_workshop import config
|
||||
|
||||
UNPINNED = '''#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "av>=18.1",
|
||||
# "numpy",
|
||||
# ]
|
||||
# ///
|
||||
import av
|
||||
av.open("x", metadata_errors="strict")
|
||||
'''
|
||||
|
||||
|
||||
def test_a_typo_in_the_config_is_named_with_the_right_spelling():
|
||||
unknown = config.unknown_keys({"paths": {"splat-transform": "/x", "pipeline": "/p"}, "frmae": {"host": ""}})
|
||||
assert unknown == [("paths.splat-transform", "paths.splat_transform"), ("frmae", "frame")]
|
||||
c = config.Config(config._merge(config.DEFAULTS, {}), None, unknown)
|
||||
keys = [k for k, _ in c.problems()]
|
||||
assert keys[:2] == ["setup.unknown_key", "setup.unknown_key"]
|
||||
|
||||
|
||||
def test_a_pipeline_that_pyav_19_breaks_is_found(tmp_path):
|
||||
for name in config.PYAV_FILES:
|
||||
(tmp_path / name).write_text(UNPINNED)
|
||||
assert config.pyav_unpinned(tmp_path) == list(config.PYAV_FILES)
|
||||
(tmp_path / "av1_colmap.py").write_text(UNPINNED.replace('"av>=18.1"', '"av>=18.1,<19"'))
|
||||
assert config.pyav_unpinned(tmp_path) == ["av1_to_3dgs.py"]
|
||||
c = config.Config(config._merge(config.DEFAULTS, {"paths": {"pipeline": str(tmp_path)}}), None)
|
||||
hint = dict(c.problems())["setup.pyav"]
|
||||
assert hint["files"] == "av1_to_3dgs.py"
|
||||
|
||||
|
||||
def test_a_conversion_failing_on_pyav_says_why():
|
||||
out = server.parse_output("Traceback ...\nTypeError: open() got an unexpected keyword argument 'metadata_errors'\n")
|
||||
assert out["info"]["hint"] == "hint.pyav"
|
||||
assert "hint" not in server.parse_output("Traceback ...\nValueError: something else\n")["info"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("existing", [False, True])
|
||||
def test_install_gives_the_full_path_of_splat_transform(tmp_path, existing):
|
||||
"""install.sh finds splat-transform on the shell's PATH: a new config gets the full path, an existing one
|
||||
a line to add – the app, started from the desktop, does not see that PATH."""
|
||||
home, xdg, bin_ = tmp_path / "home", tmp_path / "xdg", tmp_path / "bin"
|
||||
for d in (home, xdg, bin_):
|
||||
d.mkdir()
|
||||
(bin_ / "splat-transform").write_text("#!/bin/sh\n")
|
||||
(bin_ / "splat-transform").chmod(0o755)
|
||||
conf = xdg / "splat-workshop" / "config.toml"
|
||||
if existing:
|
||||
conf.parent.mkdir()
|
||||
conf.write_text('[paths]\nsplat-transform = "/typo"\n')
|
||||
env = {**os.environ, "HOME": str(home), "XDG_CONFIG_HOME": str(xdg), "XDG_DATA_HOME": str(tmp_path / "data"),
|
||||
"PATH": f"{bin_}:{os.environ['PATH']}"}
|
||||
env.pop("SPLAT_WORKSHOP_CONFIG", None)
|
||||
r = subprocess.run([str(ROOT / "install.sh")], env=env, capture_output=True, text=True, timeout=120)
|
||||
assert r.returncode == 0, r.stderr
|
||||
line = f'splat_transform = "{bin_ / "splat-transform"}"'
|
||||
if existing:
|
||||
assert line in r.stdout and line not in conf.read_text()
|
||||
assert 'Unknown setting "paths.splat-transform"' in r.stdout and '"paths.splat_transform"' in r.stdout
|
||||
else:
|
||||
assert line in conf.read_text() and "written to the config" in r.stdout
|
||||
+40
-4
@@ -15,7 +15,10 @@ const I18N = {
|
||||
"setup.hint": "Settings are in {file}. Restart Splat Workshop after changing them.",
|
||||
"setup.pipeline": "Arcturus pipeline not found in {path} (av1_to_3dgs.py). Download it from arcturus.vision/apps.",
|
||||
"setup.spirula": "Spirula Studio not found at {path}. Build it as described in the pipeline README.",
|
||||
"setup.splat_transform": "splat-transform not found – install it with: npm install -g @playcanvas/splat-transform",
|
||||
"setup.splat_transform": "splat-transform not found. Install it (without root: npm install -g --prefix ~/.local @playcanvas/splat-transform) and put its full path into {file}, e.g. splat_transform = \"/home/you/.local/bin/splat-transform\" – started from the desktop, Splat Workshop does not see a PATH set in .bashrc or .zshrc.",
|
||||
"setup.unknown_key": "Unknown setting “{key}” in {file} – did you mean “{suggestion}”? (Splat Workshop ignores it.)",
|
||||
"setup.unknown_key.plain": "Unknown setting “{key}” in {file} – Splat Workshop ignores it.",
|
||||
"setup.pyav": "The Arcturus pipeline in {path} will fail with “unexpected keyword argument 'metadata_errors'” (PyAV 19). In the # /// script header of {files}, change \"av>=18.1\" to \"av>=18.1,<19\" – see Troubleshooting in docs/arcturus-setup.md.",
|
||||
"setup.tool": "{tool} is not installed.",
|
||||
// job card
|
||||
"job.cancel": "Cancel", "job.dismiss": "Hide", "job.log": "Log",
|
||||
@@ -33,6 +36,7 @@ const I18N = {
|
||||
"res.cancelled": "Cancelled. Start again with the same name and choose “Resume” to continue.",
|
||||
"res.upload_failed": "The scene is finished, but copying to the Frame did not work (Frame off?). Use “To the Frame” in the list of finished scenes once it is on.",
|
||||
"res.failed": "Failed – the last lines of the log show why.",
|
||||
"hint.pyav": "Failed: the Arcturus pipeline does not work with PyAV 19 (“unexpected keyword argument 'metadata_errors'”). In the # /// script header of av1_to_3dgs.py and av1_colmap.py, change \"av>=18.1\" to \"av>=18.1,<19\", then resume – see Troubleshooting in docs/arcturus-setup.md.",
|
||||
"confirm.cancel": "Really cancel the conversion? With “Resume” you can continue later from the last saved state.",
|
||||
// step 1
|
||||
"s1.title": "Choose a recording", "tab.frame": "On the Frame", "tab.local": "On this PC",
|
||||
@@ -245,7 +249,7 @@ const I18N = {
|
||||
"ver.pick": "Enter a size (at least 0.01 million)", "ver.started": "Making versions …",
|
||||
"ver.none": "No versions yet.", "ver.stale": "older than the scene", "ver.delete": "Delete version",
|
||||
"ver.confirm": "Move the version “{name}” ({size}) to the trash? A copy on the Frame is not touched.",
|
||||
"job.sub.versions": "{n} versions · for {time}", "err.no_version": "This version does not exist",
|
||||
"job.sub.versions": "{n} versions · for {time}", "job.sub.versions.1": "1 version · for {time}", "err.no_version": "This version does not exist",
|
||||
"err.no_versions": "Pick at least one size",
|
||||
"ver.unframe": "Off the Frame", "ver.unframe.title": "Remove this version from the Frame's gallery (it stays on the PC)",
|
||||
"ver.unframe.confirm": "Remove “{name}” from the Frame? Its folder in the gallery and the backup copy on the Frame are deleted; the version stays on this PC.",
|
||||
@@ -264,6 +268,20 @@ const I18N = {
|
||||
"fg.move.hint": "{n} scene(s) from Splat Workshop are still in Gaussian XR's own folder, where a Steam update can delete them.",
|
||||
"fg.move": "Move to own scenes", "fg.moved": "Moved to the own scenes on the Frame",
|
||||
"ver.recommended": "(recommended)",
|
||||
"mesh.button": "Mesh …", "mesh.button.has": "Mesh ✓", "mesh.button.title": "A mesh (surface) for 3D programs and 3D printing",
|
||||
"mesh.title": "Mesh of “{name}”",
|
||||
"mesh.hint": "Spirula Studio turns the splats into a surface – for Blender and other 3D programs or for 3D printing. It stands upright (the way Blender & co. expect each format) with the floor at height 0; what lies well outside the room is left out. In metres.",
|
||||
"mesh.new": "New mesh", "mesh.have": "Mesh", "mesh.make": "Make mesh", "mesh.pick": "Pick at least one format",
|
||||
"mesh.started": "Making the mesh …", "mesh.delete": "Delete the mesh",
|
||||
"mesh.confirm": "Move the mesh of “{name}” to the trash?",
|
||||
"mesh.f.glb": "GLB (recommended)", "mesh.f.glb.text": "With colours or texture, one file – Blender, Windows 3D viewer, the web",
|
||||
"mesh.f.stl": "STL", "mesh.f.stl.text": "No colours – slicers for 3D printing",
|
||||
"mesh.f.obj": "OBJ", "mesh.f.obj.text": "With a texture (if possible), otherwise without colours – nearly every 3D program",
|
||||
"mesh.f.ply": "PLY", "mesh.f.ply.text": "Colours per corner – MeshLab, CloudCompare",
|
||||
"mesh.texture": "With a texture from the training images (sharper colours, takes a little longer)",
|
||||
"mesh.texture.none": "No texture possible: the training images were cleaned up – colours per corner from the splats",
|
||||
"mesh.time": "Takes a few minutes and about 4 GB of memory; a new mesh replaces the old one.",
|
||||
"job.sub.mesh": "Mesh · for {time}", "res.ok.mesh": "Done – the mesh of “{name}” is in {path}.", "err.no_mesh": "There is no mesh",
|
||||
"ver.clean": "Clean up", "ver.clean.off": "off",
|
||||
"ver.clean.hint": "Experimental. The scene itself stays as it is; only this version is cleaned up – compare it with one without.",
|
||||
"ver.clean.ceiling": "Ceiling", "ver.clean.ceiling.title": "A white ceiling gives the reconstruction nothing to hold on to: it stays thin, with a dark veil under it. Takes out the veil (deeper with more strength; dark things within 50 cm of a wall stay) and what lies above the ceiling, and puts in a closed ceiling in its own colour.",
|
||||
@@ -300,7 +318,10 @@ const I18N = {
|
||||
"setup.hint": "Die Einstellungen stehen in {file}. Nach Änderungen die Splat-Werkstatt neu starten.",
|
||||
"setup.pipeline": "Arcturus-Pipeline nicht gefunden in {path} (av1_to_3dgs.py). Download: arcturus.vision/apps.",
|
||||
"setup.spirula": "Spirula Studio nicht gefunden unter {path}. Bauen wie in der Pipeline-README beschrieben.",
|
||||
"setup.splat_transform": "splat-transform fehlt – installieren mit: npm install -g @playcanvas/splat-transform",
|
||||
"setup.splat_transform": "splat-transform nicht gefunden. Installieren (ohne root: npm install -g --prefix ~/.local @playcanvas/splat-transform) und den vollen Pfad in {file} eintragen, z. B. splat_transform = \"/home/du/.local/bin/splat-transform\" – vom Desktop gestartet sieht die Splat-Werkstatt keinen PATH aus .bashrc oder .zshrc.",
|
||||
"setup.unknown_key": "Unbekannte Einstellung „{key}“ in {file} – meintest du „{suggestion}“? (Die Splat-Werkstatt ignoriert sie.)",
|
||||
"setup.unknown_key.plain": "Unbekannte Einstellung „{key}“ in {file} – die Splat-Werkstatt ignoriert sie.",
|
||||
"setup.pyav": "Die Arcturus-Pipeline in {path} wird mit „unexpected keyword argument 'metadata_errors'“ scheitern (PyAV 19). Im # /// script-Kopf von {files} \"av>=18.1\" in \"av>=18.1,<19\" ändern – siehe Troubleshooting in docs/arcturus-setup.md.",
|
||||
"setup.tool": "{tool} ist nicht installiert.",
|
||||
"job.cancel": "Abbrechen", "job.dismiss": "Ausblenden", "job.log": "Protokoll",
|
||||
"job.standby": "💤 Danach in den Standby", "job.standby.in": "💤 Standby in {s} s",
|
||||
@@ -317,6 +338,7 @@ const I18N = {
|
||||
"res.cancelled": "Abgebrochen. Mit gleichem Namen erneut starten und „Fortsetzen“ wählen, um weiterzumachen.",
|
||||
"res.upload_failed": "Die Szene ist fertig gerechnet, aber das Kopieren auf die Frame hat nicht geklappt (Frame aus?). In der Liste der fertigen Szenen „Auf die Frame“ nutzen, sobald sie an ist.",
|
||||
"res.failed": "Fehlgeschlagen – die letzten Zeilen im Protokoll zeigen warum.",
|
||||
"hint.pyav": "Fehlgeschlagen: Die Arcturus-Pipeline läuft nicht mit PyAV 19 („unexpected keyword argument 'metadata_errors'“). Im # /// script-Kopf von av1_to_3dgs.py und av1_colmap.py \"av>=18.1\" in \"av>=18.1,<19\" ändern und fortsetzen – siehe Troubleshooting in docs/arcturus-setup.md.",
|
||||
"confirm.cancel": "Umwandlung wirklich abbrechen? Mit „Fortsetzen“ geht es später ab dem letzten Zwischenstand weiter.",
|
||||
"s1.title": "Aufnahme wählen", "tab.frame": "Auf der Frame", "tab.local": "Auf dem PC",
|
||||
"dl.all": "Alle holen ({n} · {size})", "dl.all.title": "Alle Aufnahmen, die noch nicht auf dem PC sind, nacheinander holen",
|
||||
@@ -524,7 +546,7 @@ const I18N = {
|
||||
"ver.pick": "Eine Größe angeben (mindestens 0,01 Mio.)", "ver.started": "Erstelle Fassungen …",
|
||||
"ver.none": "Noch keine Fassungen.", "ver.stale": "älter als die Szene", "ver.delete": "Fassung löschen",
|
||||
"ver.confirm": "Fassung „{name}“ ({size}) in den Papierkorb verschieben? Eine Kopie auf der Frame bleibt unberührt.",
|
||||
"job.sub.versions": "{n} Fassungen · seit {time}", "err.no_version": "Diese Fassung gibt es nicht",
|
||||
"job.sub.versions": "{n} Fassungen · seit {time}", "job.sub.versions.1": "1 Fassung · seit {time}", "err.no_version": "Diese Fassung gibt es nicht",
|
||||
"err.no_versions": "Mindestens eine Größe wählen",
|
||||
"ver.unframe": "Von der Frame nehmen", "ver.unframe.title": "Diese Fassung aus der Galerie der Frame entfernen (auf dem PC bleibt sie)",
|
||||
"ver.unframe.confirm": "„{name}“ von der Frame nehmen? Ihr Ordner in der Galerie und die Sicherungskopie auf der Frame werden gelöscht; auf dem PC bleibt die Fassung.",
|
||||
@@ -543,6 +565,20 @@ const I18N = {
|
||||
"fg.move.hint": "{n} Szene(n) aus der Werkstatt liegen noch im Ordner von Gaussian XR, wo ein Steam-Update sie löschen kann.",
|
||||
"fg.move": "Zu den eigenen Szenen verschieben", "fg.moved": "Zu den eigenen Szenen auf der Frame verschoben",
|
||||
"ver.recommended": "(empfohlen)",
|
||||
"mesh.button": "Mesh …", "mesh.button.has": "Mesh ✓", "mesh.button.title": "Ein Mesh (Oberfläche) für 3D-Programme und den 3D-Druck",
|
||||
"mesh.title": "Mesh von „{name}“",
|
||||
"mesh.hint": "Spirula Studio macht aus den Splats eine Oberfläche – für Blender und andere 3D-Programme oder für den 3D-Druck. Sie steht aufrecht (so, wie Blender & Co. jedes Format erwarten) mit dem Boden auf Höhe 0; was deutlich außerhalb des Raums liegt, bleibt weg. In Metern.",
|
||||
"mesh.new": "Neues Mesh", "mesh.have": "Mesh", "mesh.make": "Mesh erstellen", "mesh.pick": "Mindestens ein Format wählen",
|
||||
"mesh.started": "Erstelle das Mesh …", "mesh.delete": "Mesh löschen",
|
||||
"mesh.confirm": "Das Mesh von „{name}“ in den Papierkorb verschieben?",
|
||||
"mesh.f.glb": "GLB (empfohlen)", "mesh.f.glb.text": "Mit Farben oder Textur, eine Datei – Blender, Windows-3D-Viewer, Web",
|
||||
"mesh.f.stl": "STL", "mesh.f.stl.text": "Ohne Farben – Slicer für den 3D-Druck",
|
||||
"mesh.f.obj": "OBJ", "mesh.f.obj.text": "Mit Textur (wenn möglich), sonst ohne Farben – fast jedes 3D-Programm",
|
||||
"mesh.f.ply": "PLY", "mesh.f.ply.text": "Farben pro Ecke – MeshLab, CloudCompare",
|
||||
"mesh.texture": "Mit Textur aus den Trainingsbildern (schärfere Farben, dauert etwas länger)",
|
||||
"mesh.texture.none": "Keine Textur möglich: Die Trainingsbilder wurden aufgeräumt – Farben pro Ecke aus den Splats",
|
||||
"mesh.time": "Dauert ein paar Minuten und braucht etwa 4 GB Arbeitsspeicher; ein neues Mesh ersetzt das alte.",
|
||||
"job.sub.mesh": "Mesh · seit {time}", "res.ok.mesh": "Fertig – das Mesh von „{name}“ liegt unter {path}.", "err.no_mesh": "Es gibt kein Mesh",
|
||||
"ver.clean": "Bereinigen", "ver.clean.off": "aus",
|
||||
"ver.clean.hint": "Experimentell. Die Szene selbst bleibt, wie sie ist; nur diese Fassung wird bereinigt – am besten neben einer ohne vergleichen.",
|
||||
"ver.clean.ceiling": "Decke", "ver.clean.ceiling.title": "Eine weiße Decke gibt der Rekonstruktion keinen Halt: Sie bleibt dünn, darunter hängt ein dunkler Schleier. Nimmt den Schleier heraus (je stärker, desto tiefer; Dunkles bis 50 cm an einer Wand bleibt) und was über der Decke liegt, und setzt eine geschlossene Decke in ihrer eigenen Farbe ein.",
|
||||
|
||||
+80
-10
@@ -429,6 +429,19 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="meshDlg" class="wide">
|
||||
<h2 style="margin-top:0" id="meshTitle"></h2>
|
||||
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="mesh.hint"></p>
|
||||
<div id="meshHave" class="vlist"></div>
|
||||
<h3 data-i18n="mesh.new"></h3>
|
||||
<div id="meshFormats" class="opts"></div>
|
||||
<label class="chk" id="meshTexBox" style="margin-top:10px"><input type="checkbox" id="meshTex"> <span id="meshTexText"></span></label>
|
||||
<p class="faint" style="font-size:12px;margin:10px 0 0" data-i18n="mesh.time"></p>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:16px">
|
||||
<button id="meshClose" data-i18n="clean.close"></button>
|
||||
<button class="primary" id="meshMake" data-i18n="mesh.make"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="verDlg" class="wide">
|
||||
<h2 style="margin-top:0" id="verTitle"></h2>
|
||||
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="ver.hint"></p>
|
||||
@@ -968,8 +981,9 @@ function renderJob() {
|
||||
$("#jobName").textContent = j.name;
|
||||
const qk = QUALITY_ALIAS[j.quality] || j.quality, combine = j.kind === "combine";
|
||||
const target = tr(j.upload ? "job.toFrame" : "job.pcOnly"), time = mins(Date.now() / 1000 - j.started);
|
||||
const versions = j.kind === "versions";
|
||||
$("#jobSub").textContent = versions ? tr("job.sub.versions", {n: (j.steps || []).length, time})
|
||||
const versions = j.kind === "versions" || j.kind === "mesh";
|
||||
$("#jobSub").textContent = j.kind === "mesh" ? tr("job.sub.mesh", {time})
|
||||
: versions ? tr((j.steps || []).length === 1 ? "job.sub.versions.1" : "job.sub.versions", {n: (j.steps || []).length, time})
|
||||
: combine ? tr(j.rooms === 1 && j.recordings > 1 ? "job.sub.combine.room" : "job.sub.combine",
|
||||
{n: j.rooms === 1 && j.recordings > 1 ? j.recordings : j.rooms, align: ALIGN_LETTER[j.quality] || "?", target, time})
|
||||
: j.chain ? tr("job.sub.part", {i: j.chain.i + 1, n: j.chain.n, room: j.chain.room, q: tr("q." + qk), time})
|
||||
@@ -1013,10 +1027,10 @@ function renderJob() {
|
||||
if (!j.running) {
|
||||
if (j.result === "ok") { res.className = "result ok";
|
||||
res.textContent = j.upload ? tr("res.ok.frame", {name: j.name, folder: i.folder ? " (" + i.folder + ")" : ""})
|
||||
: tr("res.ok.local", {name: j.name, path: "runs/" + j.slug}); }
|
||||
: tr(j.kind === "mesh" ? "res.ok.mesh" : "res.ok.local", {name: j.name, path: "runs/" + j.slug + (j.kind === "mesh" ? "/mesh" : "")}); }
|
||||
else if (j.result === "cancelled") { res.className = "result warn"; res.textContent = tr("res.cancelled"); }
|
||||
else if (j.phase === 4) { res.className = "result warn"; res.textContent = tr("res.upload_failed"); }
|
||||
else { res.className = "result bad"; res.textContent = tr("res.failed"); }
|
||||
else { res.className = "result bad"; res.textContent = i.hint ? tr(i.hint) : tr("res.failed"); }
|
||||
}
|
||||
const log = $("#log"), atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 20;
|
||||
log.textContent = j.tail || ""; if (atBottom) log.scrollTop = log.scrollHeight;
|
||||
@@ -1048,7 +1062,7 @@ function sceneCard(s) {
|
||||
${s.replacing ? `<div class="fstate faint">${tr("sc.replacing")}</div>` : frameState(s)}
|
||||
<div class="acts rows${s.replacing ? " hidden" : ""}">
|
||||
${S.frameConfigured ? `<div class="arow">${uploadBtn(s)}</div>` : ""}
|
||||
<div class="arow">${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}${s.ply ? `<button class="small" data-plan="${esc(s.slug)}">${tr("sc.plan")}</button>` : ""}<button class="small pc-only" data-open="${esc(s.slug)}">${tr("sc.folder")}</button></div>
|
||||
<div class="arow">${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}${s.ply ? `<button class="small" data-mesh="${esc(s.slug)}" title="${tr("mesh.button.title")}">${tr(s.mesh ? "mesh.button.has" : "mesh.button")}</button>` : ""}${s.ply ? `<button class="small" data-plan="${esc(s.slug)}">${tr("sc.plan")}</button>` : ""}<button class="small pc-only" data-open="${esc(s.slug)}">${tr("sc.folder")}</button></div>
|
||||
<div class="arow"><button class="small icon" data-rename="${esc(s.slug)}" title="${tr("ren.button")}" aria-label="${tr("ren.button")}">✎</button><button class="small icon" data-scale="${esc(s.slug)}" title="${tr("scale.button.title")}">📏${s.scale !== 1 ? " " + ratioText(s.scale) : ""}</button><button class="small icon" data-clean="${esc(s.slug)}" title="${tr("sc.clean.title")}" aria-label="${tr("sc.clean.title")}">${tr("sc.clean")}</button><button class="small icon" data-delete="${esc(s.slug)}" title="${tr("sc.delete.title")}" aria-label="${tr("sc.delete.title")}">${tr("sc.delete")}</button></div></div>
|
||||
<div class="toggles${s.replacing ? " hidden" : ""}">${versionsToggle(s.slug, s.versions)}</div>
|
||||
</div></div>
|
||||
@@ -1096,6 +1110,7 @@ function groupBlock(g, bySlug, byGroup) {
|
||||
<button class="small${!r || r.stale ? " primary" : ""}" data-combine="${esc(g.slug)}" ${g.busy || scenes.length < 2 ? "disabled" : ""}>${tr(r ? "grp.recombine" : g.room ? "grp.room.combine" : "grp.combine")}</button>
|
||||
${r ? uploadBtn({slug: g.slug, on_frame: r.on_frame, upload: r.upload}) : ""}<i class="sep"></i>
|
||||
${r && r.ply ? `<button class="small pc-only" data-spirula="${esc(g.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}
|
||||
${r && r.ply ? `<button class="small" data-mesh="${esc(g.slug)}" title="${tr("mesh.button.title")}">${tr(g.mesh ? "mesh.button.has" : "mesh.button")}</button>` : ""}
|
||||
${scenes.some(s => s.ply) ? `<button class="small" data-gplan="${esc(g.slug)}">${tr("sc.plan")}</button>` : ""}
|
||||
${r ? `<button class="small pc-only" data-open="${esc(g.slug)}">${tr("sc.folder")}</button>` : ""}<i class="sep"></i>
|
||||
<button class="small icon" data-gedit="${esc(g.slug)}" title="${tr("grp.edit")}" aria-label="${tr("grp.edit")}">✎</button>
|
||||
@@ -1115,9 +1130,18 @@ function groupBlock(g, bySlug, byGroup) {
|
||||
</div>`;
|
||||
}
|
||||
async function loadScenes() {
|
||||
const seq = S.sceneSeq = (S.sceneSeq || 0) + 1;
|
||||
const [list, gr] = await Promise.all([api("/api/scenes"), api("/api/groups")]);
|
||||
const arr = Array.isArray(list) ? list : Object.values(list).filter(x => x && typeof x === "object" && x.slug);
|
||||
S.scenes = arr; S.groups = gr.groups || []; S.memGB = gr.mem_available_gb;
|
||||
if (seq !== S.sceneSeq) return; // a newer load is on its way: its answer counts
|
||||
S.scenes = Array.isArray(list) ? list : Object.values(list).filter(x => x && typeof x === "object" && x.slug);
|
||||
S.sceneData = gr;
|
||||
renderScenes();
|
||||
}
|
||||
// Draws the list from what was loaded last – opening or closing a group or the versions needs no new
|
||||
// load (the scene list can take seconds on a slow disk; clicks during it used to toggle back and forth)
|
||||
function renderScenes() {
|
||||
const gr = S.sceneData || {}, arr = S.scenes || [];
|
||||
S.groups = gr.groups || []; S.memGB = gr.mem_available_gb;
|
||||
const bySlug = Object.fromEntries(arr.map(s => [s.slug, s]));
|
||||
const inGroup = new Set(S.groups.flatMap(g => g.members));
|
||||
$("#groupNewBtn").classList.toggle("hidden", !arr.some(s => !inGroup.has(s.slug)));
|
||||
@@ -1137,11 +1161,12 @@ async function loadScenes() {
|
||||
const loose = arr.filter(s => !inGroup.has(s.slug));
|
||||
$("#sceneGrid").innerHTML = loose.length ? loose.map(sceneCard).join("")
|
||||
: S.groups.length ? "" : `<div class="empty" style="grid-column:1/-1">${tr("scenes.none")}</div>`;
|
||||
document.querySelectorAll("[data-gtoggle]").forEach(b => b.onclick = () => { groupOpen(b.dataset.gtoggle, !groupOpen(b.dataset.gtoggle)); loadScenes(); });
|
||||
document.querySelectorAll("[data-vtoggle]").forEach(b => b.onclick = () => { versionsOpen(b.dataset.vtoggle, !versionsOpen(b.dataset.vtoggle)); loadScenes(); });
|
||||
document.querySelectorAll("[data-gtoggle]").forEach(b => b.onclick = () => { groupOpen(b.dataset.gtoggle, !groupOpen(b.dataset.gtoggle)); renderScenes(); });
|
||||
document.querySelectorAll("[data-vtoggle]").forEach(b => b.onclick = () => { versionsOpen(b.dataset.vtoggle, !versionsOpen(b.dataset.vtoggle)); renderScenes(); });
|
||||
fillVersionLists();
|
||||
document.querySelectorAll("[data-scale]").forEach(b => b.onclick = () => openScale(b.dataset.scale));
|
||||
document.querySelectorAll("[data-versions]").forEach(b => b.onclick = () => openVersions(b.dataset.versions));
|
||||
document.querySelectorAll("[data-mesh]").forEach(b => b.onclick = () => openMesh(b.dataset.mesh));
|
||||
document.querySelectorAll("[data-gedit]").forEach(b => b.onclick = () => openGroupDlg(S.groups.find(g => g.slug === b.dataset.gedit)));
|
||||
document.querySelectorAll("[data-gdelete]").forEach(b => b.onclick = () => deleteGroup(S.groups.find(g => g.slug === b.dataset.gdelete)));
|
||||
document.querySelectorAll("[data-combine]").forEach(b => b.onclick = () => openCombine(S.groups.find(g => g.slug === b.dataset.combine)));
|
||||
@@ -1325,6 +1350,51 @@ async function loadFrameGallery(refresh) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- meshes ---------- */
|
||||
const MESH_FORMATS = ["glb", "stl", "obj", "ply"];
|
||||
async function openMesh(slug) {
|
||||
const g = (S.groups || []).find(x => x.slug === slug), x = g || S.scenes.find(y => y.slug === slug);
|
||||
if (!x) return;
|
||||
$("#meshTitle").textContent = tr("mesh.title", {name: x.name});
|
||||
const draw = async () => {
|
||||
const m = await api("/api/mesh?slug=" + encodeURIComponent(slug));
|
||||
$("#meshHave").innerHTML = m.made ? `<div class="vrow"><div class="vwhat">
|
||||
<b>${tr("mesh.have")}${m.made.stale ? " · " + tr("ver.stale") : ""}</b>
|
||||
<span class="muted">${m.files.map(f => esc(f.name) + " (" + mb(f.size) + ")").join(" · ")}</span></div>
|
||||
<div class="vacts"><button class="small pc-only" id="meshOpen">${tr("sc.folder")}</button>
|
||||
<button class="small" id="meshDel" title="${tr("mesh.delete")}" aria-label="${tr("mesh.delete")}">${tr("sc.delete")}</button></div></div>` : "";
|
||||
if (m.made) {
|
||||
$("#meshOpen").onclick = () => api("/api/open", {slug, what: "mesh"});
|
||||
$("#meshDel").onclick = async () => {
|
||||
if (!confirm(tr("mesh.confirm", {name: x.name}))) return;
|
||||
const r = await api("/api/mesh/delete", {slug});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
draw(); loadScenes();
|
||||
};
|
||||
}
|
||||
$("#meshTex").disabled = !m.texture_possible;
|
||||
$("#meshTex").classList.toggle("hidden", !m.texture_possible); // not possible: only the note
|
||||
$("#meshTex").checked = m.texture_possible;
|
||||
$("#meshTexText").textContent = tr(m.texture_possible ? "mesh.texture" : "mesh.texture.none");
|
||||
};
|
||||
const was = S.meshFormats || ["glb"];
|
||||
$("#meshFormats").innerHTML = MESH_FORMATS.map(f => `<label class="opt${was.includes(f) ? " active" : ""}">
|
||||
<input type="checkbox" value="${f}" ${was.includes(f) ? "checked" : ""}><div><b>${tr("mesh.f." + f)}</b><span>${tr("mesh.f." + f + ".text")}</span></div></label>`).join("");
|
||||
$("#meshFormats").querySelectorAll("input").forEach(i => i.onchange = () => i.closest(".opt").classList.toggle("active", i.checked));
|
||||
await draw();
|
||||
$("#meshDlg").showModal();
|
||||
$("#meshClose").onclick = () => $("#meshDlg").close();
|
||||
$("#meshMake").onclick = async () => {
|
||||
const formats = [...$("#meshFormats").querySelectorAll("input:checked")].map(i => i.value);
|
||||
if (!formats.length) { toast(tr("mesh.pick")); return; }
|
||||
S.meshFormats = formats;
|
||||
const r = await api("/api/mesh", {slug, formats: formats.join(","), texture: $("#meshTex").checked});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
$("#meshDlg").close(); toast(tr("mesh.started")); await poll();
|
||||
$("#jobCard").scrollIntoView({behavior: "smooth", block: "start"});
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- versions ---------- */
|
||||
// Frame sizes as measured on a Steam Frame (Gaussian XR, 10/2026): 1 M is nearly as fast as 0.75 M and clearly sharper
|
||||
const FRAME_SIZES = [[0.75, 36], [1, 35], [1.5, 29], [2, 24]], FRAME_RECOMMENDED = 1; // million splats, fps
|
||||
@@ -1348,7 +1418,7 @@ function versionRow(v) {
|
||||
<span class="faint">${tr("million", {n: num(v.splats / 1e6, 2)})} · ${mb(v.size)}${v.stale ? " · " + tr("ver.stale") : ""}</span></div>
|
||||
<div class="vacts">${v.on_frame === null && !(v.upload && (v.upload.running || v.upload.error)) ? "" : frameState({upload: v.upload, on_frame: v.on_frame})}
|
||||
${v.ply ? `<button class="small pc-only" data-vspirula="${esc(v.key)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}
|
||||
${S.frameConfigured ? `<button class="small" data-vup="${esc(v.key)}" ${v.upload && v.upload.running ? "disabled" : ""} ${slow ? `title="${tr("ver.slow", {n: num(v.splats / 1e6, 1)})}"` : ""}>${tr(v.on_frame ? "sc.reupload" : "sc.toFrame")}${slow ? " ⚠️" : ""}</button>` : ""}
|
||||
${S.frameConfigured ? `<button class="small" data-vup="${esc(v.key)}" ${(v.upload && v.upload.running) || v.on_frame === null ? "disabled" : ""} ${slow ? `title="${tr("ver.slow", {n: num(v.splats / 1e6, 1)})}"` : ""}>${tr(v.on_frame ? "sc.reupload" : "sc.toFrame")}${slow ? " ⚠️" : ""}</button>` : ""}
|
||||
${v.on_frame ? `<button class="small" data-vunframe="${esc(v.key)}" title="${tr("ver.unframe.title")}">${tr("ver.unframe")}</button>` : ""}
|
||||
<button class="small" data-vtemplate="${esc(v.key)}" title="${tr("ver.template.title")}">${tr("ver.template")}</button>
|
||||
<button class="small" data-vdel="${esc(v.key)}" title="${tr("ver.delete")}" aria-label="${tr("ver.delete")}">${tr("sc.delete")}</button></div>
|
||||
|
||||
Reference in new issue
Block a user