mirror of
https://github.com/thunderheronight/splat-workshop.git
synced 2026-10-18 16:01:32 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94b4cd431a | ||
|
|
bfe3939754 |
No files matched your search
@@ -2,6 +2,18 @@
|
||||
|
||||
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.6.1] – 2026-10-17
|
||||
|
||||
### Changed
|
||||
|
||||
- **Which way is up – pick the picture**: the import dialog shows a small picture for each choice, the scene as it would stand on the Frame seen from its start, so you simply pick the one standing upright. Our guess is preselected and marked (the vertical is the smaller of the scene's Y and Z extents).
|
||||
- The choices say what you see: *Upright in Blender (Z up)*, *Like the Workshop's and the Frame's scenes (Y down)*, *Upside down (Y up)*.
|
||||
- The pictures take a second or two (a few more for `.sog`, `.spz` and the like, which are read through splat-transform) and are kept, so picking the same file again is instant.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Scenes made from photos in Spirula Studio lay on their side**: 0.6.0 said Spirula Studio files have Y down and preselected that. That is only true for the Frame's own scenes, which are aligned to its tracking; a scene Spirula Studio makes from photos alone (`spirula sfm auto`) has Z up – checked with a scene made from 70 photos. The dialog now says so, and the guess picks Z up for such files.
|
||||
|
||||
## [0.6.0] – 2026-10-17
|
||||
|
||||
### Added
|
||||
@@ -196,6 +208,7 @@ First release.
|
||||
- **Floor plans** (experimental) in two styles with a measuring tool.
|
||||
- Configurable video player; English and German interface.
|
||||
|
||||
[0.6.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.5.1...v0.6.0
|
||||
[0.5.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.4.2...v0.5.0
|
||||
|
||||
+2
-2
@@ -24,9 +24,9 @@ Arcturus Vision stellt die Rekonstruktions-Skripte und einen Spirula-Studio-Fork
|
||||
|
||||
## Neu in 0.6
|
||||
|
||||
- 📥 **Eigene Splats importieren** – eine Splat-Datei aus einem anderen Programm (`.ply`, `.sog`, `.spz`, `.splat`, `.ksplat`) wird zu einer Szene wie die anderen: auf die Frame bringen, Fassungen oder ein Mesh machen. Du sagst, wo oben ist, und bei Bedarf die Größe.
|
||||
- 📥 **Eigene Splats importieren** – eine Splat-Datei aus einem anderen Programm (`.ply`, `.sog`, `.spz`, `.splat`, `.ksplat`) wird zu einer Szene wie die anderen: auf die Frame bringen, Fassungen oder ein Mesh machen. Du wählst das Bild, in dem sie aufrecht steht – das wahrscheinliche ist vorausgewählt – und bei Bedarf die Größe.
|
||||
|
||||
<img src="docs/import.png" width="420" alt="Der Import-Dialog: Datei wählen, benennen, sagen, wo oben ist, und die Größe">
|
||||
<img src="docs/import.png" width="420" alt="Der Import-Dialog: Datei wählen, benennen, das Bild wählen, in dem die Szene aufrecht steht, und die Größe">
|
||||
<br><sub>Englische Oberfläche.</sub>
|
||||
|
||||
## Neu in 0.5
|
||||
|
||||
@@ -26,9 +26,9 @@ Arcturus Vision publishes the reconstruction scripts and a Spirula Studio fork,
|
||||
|
||||
## New in 0.6
|
||||
|
||||
- 📥 **Import your own splats** – a splat file from another program (`.ply`, `.sog`, `.spz`, `.splat`, `.ksplat`) becomes a scene like the others: copy it to the Frame, make versions or a mesh. You say which way is up and, if needed, the size.
|
||||
- 📥 **Import your own splats** – a splat file from another program (`.ply`, `.sog`, `.spz`, `.splat`, `.ksplat`) becomes a scene like the others: copy it to the Frame, make versions or a mesh. Pick the picture in which it stands upright – the likely one is preselected – and, if needed, the size.
|
||||
|
||||
<img src="docs/import.png" width="420" alt="The import dialog: choose a file, name it, say which way is up and the size">
|
||||
<img src="docs/import.png" width="420" alt="The import dialog: choose a file, name it, pick the picture in which the scene stands upright, and the size">
|
||||
|
||||
## New in 0.5
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 84 KiB |
+2
-1
@@ -4,7 +4,8 @@
|
||||
#
|
||||
# Usage: scripts/import.sh <splat file> <run folder> "<display name>" [up] [scale]
|
||||
# <splat file> .ply .compressed.ply .sog .spz .splat .ksplat (whatever splat-transform reads)
|
||||
# up where "up" is in the file: y-down (Spirula Studio, COLMAP, the Frame – default), y-up, z-up
|
||||
# up where "up" is in the file: y-down (COLMAP, the Workshop's own scenes – default), y-up,
|
||||
# z-up (Blender, Spirula Studio from photos alone)
|
||||
# scale factor for the size, e.g. 0.01 if the file is in centimetres (default 1)
|
||||
# Writes <run folder>/ with spirula/scene.ply, scene.sog, scene.xrSceneTransform.json, a preview from the
|
||||
# top view and a name plate. Lines starting with "@@" are progress markers.
|
||||
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["numpy", "pillow"]
|
||||
# ///
|
||||
"""Small pictures of a splat file for the import dialog (0.6.1): one per choice of "up", as the scene would
|
||||
stand on the Frame, so the upright one can simply be picked – plus a guess which one it is.
|
||||
|
||||
Usage: python3 import_preview.py <splat file> <out dir> [splat-transform]
|
||||
Writes <out dir>/y-down.png, y-up.png, z-up.png and prints {"guess": "..."} as JSON.
|
||||
Files other than a plain PLY (.sog .spz .splat .ksplat .compressed.ply) are turned into one first with
|
||||
splat-transform.
|
||||
|
||||
Each view is a small picture as from the Frame at the scene's start, looking level, with Y pointing down as in
|
||||
our scenes. The turns are the ones scripts/import.sh makes.
|
||||
The guess (see guess()) only preselects one: the pictures decide.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
TURNS = { # file -> our scene (Y down), as scripts/import.sh
|
||||
"y-down": np.eye(3),
|
||||
"y-up": np.array([[1, 0, 0], [0, -1, 0], [0, 0, -1]], dtype=np.float64), # 180° about X
|
||||
"z-up": np.array([[1, 0, 0], [0, 0, -1], [0, 1, 0]], dtype=np.float64), # +Z becomes -Y
|
||||
}
|
||||
W, H = 320, 200
|
||||
MAX_POINTS = 2_000_000
|
||||
FOV = 100 # degrees across, about what the Frame shows
|
||||
SH_C0 = 0.28209479177387814
|
||||
BG = (24, 27, 34)
|
||||
|
||||
|
||||
def plain_ply(path: Path) -> bool:
|
||||
"""A PLY with one float per property and the usual splat columns (what load() reads)."""
|
||||
if path.suffix.lower() != ".ply":
|
||||
return False
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(64 * 1024)
|
||||
if b"end_header" not in head:
|
||||
return False
|
||||
lines = head.split(b"end_header")[0].decode(errors="replace").splitlines()
|
||||
props = [ln.split() for ln in lines if ln.startswith("property")]
|
||||
names = {p[-1] for p in props}
|
||||
return (sum(ln.startswith("element") for ln in lines) == 1 and all(p[1] == "float" for p in props)
|
||||
and {"x", "y", "z", "opacity", "f_dc_0", "f_dc_1", "f_dc_2"} <= names
|
||||
and "format binary_little_endian 1.0" in lines)
|
||||
|
||||
|
||||
def load(path: Path):
|
||||
"""x, y, z, rgb and a size per splat that is not nearly clear (memory-mapped, at most MAX_POINTS of them)."""
|
||||
with open(path, "rb") as f:
|
||||
props, n = [], 0
|
||||
while (raw := f.readline()) and (line := raw.decode(errors="replace").strip()) != "end_header":
|
||||
if line.startswith("element vertex"):
|
||||
n = int(line.split()[-1])
|
||||
elif line.startswith("property"):
|
||||
props.append(line.split()[-1])
|
||||
if not raw or not n:
|
||||
sys.exit(f"{path.name}: not a splat file")
|
||||
offset = f.tell()
|
||||
data = np.memmap(path, dtype=np.dtype([(p, "<f4") for p in props]), mode="r", offset=offset, shape=(n,))
|
||||
rng = np.random.default_rng(0)
|
||||
d = data[np.sort(rng.choice(n, MAX_POINTS, replace=False))] if n > MAX_POINTS else data[:]
|
||||
d = d[1 / (1 + np.exp(-d["opacity"].astype(np.float64))) > 0.1]
|
||||
xyz = np.stack([d["x"], d["y"], d["z"]], axis=1).astype(np.float64)
|
||||
rgb = np.clip(0.5 + SH_C0 * np.stack([d["f_dc_0"], d["f_dc_1"], d["f_dc_2"]], axis=1), 0, 1).astype(np.float32)
|
||||
if {"scale_0", "scale_1", "scale_2"} <= set(props):
|
||||
sc = np.stack([d["scale_0"], d["scale_1"], d["scale_2"]], axis=1).astype(np.float64)
|
||||
size = np.exp(np.clip(sc.max(axis=1), -20, 5))
|
||||
else:
|
||||
size = np.full(len(d), 0.01)
|
||||
ok = np.isfinite(xyz).all(axis=1) & np.isfinite(size)
|
||||
return xyz[ok], rgb[ok], size[ok]
|
||||
|
||||
|
||||
def eye_of(xyz: np.ndarray) -> np.ndarray:
|
||||
"""Where to look from: the file's origin if it lies in the scene (the Frame's start, the middle of the
|
||||
cameras for COLMAP-based tools) – otherwise the middle of the splats."""
|
||||
lo, hi = np.percentile(xyz, 5, axis=0), np.percentile(xyz, 95, axis=0)
|
||||
return np.zeros(3) if ((lo < 0) & (hi > 0)).all() else np.median(xyz, axis=0)
|
||||
|
||||
|
||||
def guess(xyz: np.ndarray) -> str:
|
||||
"""The vertical is the smaller of the Y and Z extents (a room is wider than it is high, so is a landscape;
|
||||
X is never up in the choices). Along Y, there is usually more down by the floor than up by the ceiling."""
|
||||
lo, hi = np.percentile(xyz, 2, axis=0), np.percentile(xyz, 98, axis=0)
|
||||
ext = hi - lo
|
||||
if ext[2] < ext[1]:
|
||||
return "z-up"
|
||||
return "y-down" if np.median(xyz[:, 1]) >= (lo[1] + hi[1]) / 2 else "y-up" # Y down: the floor has the larger y
|
||||
|
||||
|
||||
def view(xyz: np.ndarray, rgb: np.ndarray, size: np.ndarray, eye: np.ndarray) -> Image.Image:
|
||||
"""A small picture as from the Frame at the scene's start: from eye, looking level along +z, x to the
|
||||
right and y (down) downwards; each splat a dot as big as it looks, the nearest one in each pixel wins."""
|
||||
p = xyz - eye
|
||||
z = p[:, 2]
|
||||
near = 0.04 * float((np.percentile(xyz, 95, axis=0) - np.percentile(xyz, 5, axis=0)).max())
|
||||
f = W / 2 / np.tan(np.radians(FOV / 2))
|
||||
m = z > near # what floats right in front of the eye hides all
|
||||
p, z, c, sz = p[m], z[m], rgb[m], size[m]
|
||||
u = np.round(W / 2 + f * p[:, 0] / z).astype(int)
|
||||
v = np.round(H / 2 + f * p[:, 1] / z).astype(int)
|
||||
r = np.clip(np.round(f * sz / z * 0.5), 0, 2).astype(int)
|
||||
ok = (u >= -3) & (u < W + 3) & (v >= -3) & (v < H + 3)
|
||||
u, v, z, c, r = u[ok], v[ok], z[ok], c[ok], r[ok]
|
||||
pu, pv, pz, pc = [], [], [], []
|
||||
for rad in range(3): # each dot as a small square of its size
|
||||
k = r == rad
|
||||
for dy in range(-rad, rad + 1):
|
||||
for dx in range(-rad, rad + 1):
|
||||
pu.append(u[k] + dx); pv.append(v[k] + dy); pz.append(z[k]); pc.append(c[k])
|
||||
u, v, z, c = np.concatenate(pu), np.concatenate(pv), np.concatenate(pz), np.concatenate(pc)
|
||||
ok = (u >= 0) & (u < W) & (v >= 0) & (v < H)
|
||||
u, v, z, c = u[ok], v[ok], z[ok], c[ok]
|
||||
img = np.zeros((H, W, 3), np.float32)
|
||||
img[:] = BG
|
||||
if len(z):
|
||||
order = np.argsort(-z) # far first: the near ones are written last
|
||||
c = c / max(float(np.percentile(c.max(axis=1), 95)), 0.05) * 0.9 # rooms come out dim otherwise
|
||||
img[v[order], u[order]] = np.clip(c[order], 0, 1) * 255
|
||||
return Image.fromarray(img.astype(np.uint8))
|
||||
|
||||
|
||||
def main():
|
||||
src, out = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
ply = src
|
||||
if not plain_ply(src):
|
||||
if len(sys.argv) < 4 or not sys.argv[3]:
|
||||
sys.exit("splat-transform is needed for this file")
|
||||
ply = out / "as.ply"
|
||||
if subprocess.run([sys.argv[3], "-w", str(src), str(ply)], stdout=subprocess.DEVNULL).returncode:
|
||||
sys.exit(f"{src.name}: splat-transform cannot read it")
|
||||
xyz, rgb, size = load(ply)
|
||||
if ply != src:
|
||||
ply.unlink()
|
||||
if not len(xyz):
|
||||
sys.exit("no splats in the file")
|
||||
eye = eye_of(xyz)
|
||||
for up, r in TURNS.items():
|
||||
view(xyz @ r.T, rgb, size, eye @ r.T).save(out / f"{up}.png")
|
||||
print(json.dumps({"guess": guess(xyz)}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -9,6 +9,7 @@ Without the tray icon (--no-idle-exit) it stops after idle_minutes with nothing
|
||||
Start: ./splat-workshop (or python3 -m splat_workshop.server)
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
import http.server
|
||||
@@ -30,7 +31,7 @@ from pathlib import Path
|
||||
from . import check, config
|
||||
from .i18n import T, detect
|
||||
|
||||
VERSION = "0.6.0"
|
||||
VERSION = "0.6.1"
|
||||
CFG = config.load()
|
||||
ROOT = config.ROOT
|
||||
WEB = ROOT / "web"
|
||||
@@ -1352,6 +1353,35 @@ def start_import(p: dict):
|
||||
return None
|
||||
|
||||
|
||||
def import_preview(p: dict) -> dict:
|
||||
"""Small pictures of the picked file, one per choice of "up", and a guess which one stands upright
|
||||
(scripts/import_preview.py) – kept per file, so picking it again is instant."""
|
||||
src = Path(p.get("path") or "")
|
||||
if not src.is_absolute() or not src.is_file() or src.suffix.lower() not in SPLAT_EXTS:
|
||||
return {"error": err("err.import_file")}
|
||||
st = src.stat()
|
||||
key = hashlib.sha1(f"{src}|{st.st_size}|{st.st_mtime_ns}".encode()).hexdigest()[:16]
|
||||
out = CACHE / "import" / key
|
||||
if not (out / "guess.json").exists():
|
||||
tmp = out.with_name(key + ".tmp")
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
try:
|
||||
r = subprocess.run(["uv", "run", "-q", str(SCRIPTS / "import_preview.py"), str(src), str(tmp),
|
||||
str(CFG.splat_transform or "")], capture_output=True, text=True, timeout=300)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
r = None
|
||||
if r is None or r.returncode:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
return {"error": err("err.import_preview", detail=((r.stderr if r else "").strip().splitlines() or [""])[-1])}
|
||||
(tmp / "guess.json").write_text(r.stdout.strip().splitlines()[-1])
|
||||
shutil.rmtree(out, ignore_errors=True)
|
||||
tmp.rename(out)
|
||||
guess = json.loads((out / "guess.json").read_text()).get("guess")
|
||||
return {"guess": guess if guess in IMPORT_UP else "y-down",
|
||||
"images": {u: "data:image/png;base64," + base64.b64encode((out / f"{u}.png").read_bytes()).decode()
|
||||
for u in IMPORT_UP}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- is there a newer version?
|
||||
|
||||
REPO = "thunderheronight/splat-workshop"
|
||||
@@ -2645,7 +2675,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(400, {"error": err("err.bad_request")})
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
e = None
|
||||
if path in ("/api/pick", "/api/play", "/api/open", "/api/shutdown", "/api/import") and not self._from_this_pc():
|
||||
if path in ("/api/pick", "/api/play", "/api/open", "/api/shutdown", "/api/import", "/api/import/preview") \
|
||||
and not self._from_this_pc():
|
||||
# These open windows on this PC's screen or stop the program – only allowed from the PC itself
|
||||
return self.send(403, {"error": err("err.pc_only")})
|
||||
if path == "/api/start":
|
||||
@@ -2678,6 +2709,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
e = move_to_own_folder()
|
||||
elif path == "/api/import":
|
||||
e = start_import(p)
|
||||
elif path == "/api/import/preview":
|
||||
r = import_preview(p)
|
||||
return self.send(400 if "error" in r else 200, r)
|
||||
elif path == "/api/update/check":
|
||||
return self.send(200, check_update_now())
|
||||
elif path == "/api/mesh":
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""Your own splats (0.6, scripts/import.sh): a splat file from another program becomes a scene like the
|
||||
others, and goes to the Frame."""
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from conftest import RUNS, server, tools_called, wait_job
|
||||
import test_clean
|
||||
@@ -80,3 +84,67 @@ def test_odd_imports_are_refused(api, splat, body, code):
|
||||
status, r = api.post("/api/import", {"path": str(path), "name": "Garden", **body})
|
||||
assert status == 400 and r["error"]["code"] == code
|
||||
assert server.load_state().get("job") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- which way is up (0.6.1)
|
||||
|
||||
def turned(src, dst, up):
|
||||
"""The room as another program would have saved it: our scene (Y down) turned back into "up"."""
|
||||
v = test_clean.read(src).copy()
|
||||
x, y, z = v["x"].copy(), v["y"].copy(), v["z"].copy()
|
||||
if up == "y-up":
|
||||
v["y"], v["z"] = -y, -z # 180° about X
|
||||
elif up == "z-up":
|
||||
v["y"], v["z"] = z, -y # down (+Y) becomes -Z
|
||||
header = "ply\nformat binary_little_endian 1.0\nelement vertex %d\n%send_header\n" % (
|
||||
len(v), "".join(f"property float {p}\n" for p in v.dtype.names))
|
||||
dst.write_bytes(header.encode() + v.tobytes())
|
||||
return dst
|
||||
|
||||
|
||||
def picture(url):
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
return Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1]))).convert("RGB")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("up", ["y-down", "y-up", "z-up"])
|
||||
def test_the_dialog_guesses_which_way_is_up(api, splat, tmp_path, up):
|
||||
f = splat if up == "y-down" else turned(splat, tmp_path / f"room_{up}.ply", up)
|
||||
status, r = api.post("/api/import/preview", {"path": str(f)})
|
||||
assert status == 200, r
|
||||
assert r["guess"] == up
|
||||
assert sorted(r["images"]) == ["y-down", "y-up", "z-up"]
|
||||
for u, url in r["images"].items():
|
||||
assert picture(url).size == (320, 200), u
|
||||
|
||||
|
||||
def test_the_upright_picture_has_the_floor_below(api, splat, tmp_path):
|
||||
# the room's floor is brown, its ceiling nearly white: saved with Y up, the right choice shows brown at
|
||||
# the bottom, our usual Y down at the top (the scene on its head)
|
||||
f = turned(splat, tmp_path / "room_yup.ply", "y-up")
|
||||
imgs = {u: np.asarray(picture(url), np.float32) for u, url in api.post("/api/import/preview", {"path": str(f)})[1]["images"].items()}
|
||||
|
||||
def brown(img): # floor colour: much more red than blue
|
||||
return (img[..., 0] - img[..., 2]) > 40
|
||||
|
||||
up, head = brown(imgs["y-up"]), brown(imgs["y-down"])
|
||||
assert up[100:].sum() > 5 * up[:100].sum()
|
||||
assert head[:100].sum() > 5 * head[100:].sum()
|
||||
|
||||
|
||||
def test_the_pictures_are_kept_per_file(api, splat, monkeypatch):
|
||||
assert api.post("/api/import/preview", {"path": str(splat)})[0] == 200
|
||||
calls = []
|
||||
real = server.subprocess.run
|
||||
monkeypatch.setattr(server.subprocess, "run", lambda *a, **k: calls.append(a) or real(*a, **k))
|
||||
assert api.post("/api/import/preview", {"path": str(splat)})[0] == 200
|
||||
assert calls == [] # the same file: from the cache
|
||||
|
||||
|
||||
def test_a_broken_file_gives_no_pictures(api, tmp_path):
|
||||
bad = tmp_path / "broken.ply"
|
||||
bad.write_bytes(b"not a splat")
|
||||
status, r = api.post("/api/import/preview", {"path": str(bad)})
|
||||
assert status == 400 and r["error"]["code"] == "err.import_preview"
|
||||
status, r = api.post("/api/import/preview", {"path": "relative.ply"})
|
||||
assert status == 400 and r["error"]["code"] == "err.import_file"
|
||||
@@ -100,7 +100,7 @@ def test_no_paths_outside_the_runs_folder(api, leak, slug):
|
||||
|
||||
@pytest.mark.parametrize("path,body", [
|
||||
("/api/open", {"slug": "Room", "what": "folder"}), ("/api/play", {"name": "x.mp4"}),
|
||||
("/api/pick", {}), ("/api/shutdown", {}),
|
||||
("/api/pick", {}), ("/api/shutdown", {}), ("/api/import", {"path": "/x.ply"}), ("/api/import/preview", {"path": "/x.ply"}),
|
||||
])
|
||||
def test_actions_on_the_screen_only_from_this_pc(api, monkeypatch, path, body):
|
||||
# video player, file dialog, folder, Spirula and quitting: not from another device in the LAN
|
||||
|
||||
+14
-8
@@ -275,10 +275,13 @@ const I18N = {
|
||||
"imp.button": "+ Import a splat …", "imp.button.title": "A splat file from another program as a scene",
|
||||
"imp.title": "Import a splat", "imp.hint": "A splat file from another program becomes a scene like the others. The file itself is not changed.",
|
||||
"imp.pick": "Choose file …", "imp.none": "No file chosen (.ply, .sog, .spz, .splat, .ksplat)", "imp.name": "Name",
|
||||
"imp.up": "Which way is up in the file?",
|
||||
"imp.up.y-down": "Y down (usual)", "imp.up.y-down.text": "Spirula Studio, COLMAP and most tools built on it – like the Frame's own scenes",
|
||||
"imp.up.y-up": "Y up", "imp.up.y-up.text": "If the scene stands upside down on the Frame",
|
||||
"imp.up.z-up": "Z up", "imp.up.z-up.text": "If the scene lies on its side on the Frame (e.g. from Blender)",
|
||||
"imp.up": "Which way does it stand upright?",
|
||||
"imp.up.hint": "The pictures show the scene as it would stand on the Frame – pick the upright one. Marked: our guess.",
|
||||
"imp.up.z-up": "Upright in Blender (Z up)", "imp.up.z-up.text": "e.g. from Blender, or made from photos in Spirula Studio",
|
||||
"imp.up.y-down": "Like the Workshop's and the Frame's scenes (Y down)", "imp.up.y-down.text": "COLMAP and most tools built on it",
|
||||
"imp.up.y-up": "Upside down (Y up)", "imp.up.y-up.text": "If it stands on its head with “Y down”",
|
||||
"imp.guess": "guess", "imp.preview.wait": "Making the pictures …", "imp.preview.none": "No pictures: {detail}",
|
||||
"err.import_preview": "Could not read the file: {detail}",
|
||||
"imp.scale": "Size × ", "imp.scale.hint": "1 = as in the file; 0.01 if it is in centimetres. Photo reconstructions have no real scale – try it on the Frame.",
|
||||
"imp.make": "Import", "imp.started": "Importing …", "job.sub.import": "Import · for {time}",
|
||||
"err.import_file": "Choose a splat file (.ply, .sog, .spz, .splat, .ksplat)",
|
||||
@@ -588,10 +591,13 @@ const I18N = {
|
||||
"imp.button": "+ Splat importieren …", "imp.button.title": "Eine Splat-Datei aus einem anderen Programm als Szene aufnehmen",
|
||||
"imp.title": "Splat importieren", "imp.hint": "Eine Splat-Datei aus einem anderen Programm wird zu einer Szene wie die anderen. Die Datei selbst bleibt unverändert.",
|
||||
"imp.pick": "Datei wählen …", "imp.none": "Keine Datei gewählt (.ply, .sog, .spz, .splat, .ksplat)", "imp.name": "Name",
|
||||
"imp.up": "Wo ist oben in der Datei?",
|
||||
"imp.up.y-down": "Y unten (üblich)", "imp.up.y-down.text": "Spirula Studio, COLMAP und die meisten Werkzeuge darauf – wie die Szenen der Frame selbst",
|
||||
"imp.up.y-up": "Y oben", "imp.up.y-up.text": "Wenn die Szene auf der Frame auf dem Kopf steht",
|
||||
"imp.up.z-up": "Z oben", "imp.up.z-up.text": "Wenn die Szene auf der Frame auf der Seite liegt (z. B. aus Blender)",
|
||||
"imp.up": "Wie steht sie aufrecht?",
|
||||
"imp.up.hint": "Die Bilder zeigen die Szene so, wie sie auf der Frame stünde – wähle die aufrechte. Markiert: unsere Vermutung.",
|
||||
"imp.up.z-up": "Steht in Blender richtig (Z oben)", "imp.up.z-up.text": "z. B. aus Blender, oder in Spirula Studio aus Fotos gemacht",
|
||||
"imp.up.y-down": "Wie die Szenen der Werkstatt / Frame (Y unten)", "imp.up.y-down.text": "COLMAP und die meisten Werkzeuge darauf",
|
||||
"imp.up.y-up": "Steht auf dem Kopf (Y oben)", "imp.up.y-up.text": "Wenn sie mit „Y unten“ auf dem Kopf steht",
|
||||
"imp.guess": "vermutet", "imp.preview.wait": "Bilder werden berechnet …", "imp.preview.none": "Keine Bilder: {detail}",
|
||||
"err.import_preview": "Datei nicht lesbar: {detail}",
|
||||
"imp.scale": "Größe × ", "imp.scale.hint": "1 = wie in der Datei; 0,01, wenn sie in Zentimetern ist. Aus Fotos rekonstruierte Szenen haben keinen echten Maßstab – auf der Frame ausprobieren.",
|
||||
"imp.make": "Importieren", "imp.started": "Importiere …", "job.sub.import": "Import · seit {time}",
|
||||
"err.import_file": "Eine Splat-Datei wählen (.ply, .sog, .spz, .splat, .ksplat)",
|
||||
|
||||
+27
-5
@@ -192,6 +192,12 @@ dialog.wide { max-width: 560px; }
|
||||
dialog select { background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; font: inherit; }
|
||||
dialog h3 { font-size: 13.5px; margin: 16px 0 8px; }
|
||||
.opts { display: grid; gap: 6px; }
|
||||
.opts.pics { grid-template-columns: repeat(3, 1fr); }
|
||||
.opts.pics .opt { flex-direction: column; gap: 8px; }
|
||||
.opts.pics .pic { width: 100%; aspect-ratio: 8 / 5; border-radius: 6px; background: #181b22; object-fit: cover; display: block; }
|
||||
.opts.pics .pic.wait { display: grid; place-items: center; font-size: 12px; color: var(--muted); text-align: center; padding: 6px; box-sizing: border-box; }
|
||||
.opts.pics .guess { font-size: 11px; color: var(--accent); border: 1px solid var(--accent); border-radius: 99px; padding: 0 6px; margin-left: 4px; white-space: nowrap; }
|
||||
@media (max-width: 620px) { .opts.pics { grid-template-columns: 1fr; } }
|
||||
.unfin { display: grid; gap: 8px; }
|
||||
.unfin .row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.unfin .row b { overflow-wrap: anywhere; } .unfin .row .sp { flex: 1; }
|
||||
@@ -451,7 +457,8 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
<div class="row" style="gap:8px"><button class="small" id="impPick" data-i18n="imp.pick"></button><span id="impFile" class="muted" style="overflow-wrap:anywhere"></span></div>
|
||||
<label class="row" style="gap:8px;margin-top:12px"><span data-i18n="imp.name"></span><input id="impName" type="text" style="flex:1"></label>
|
||||
<h3 data-i18n="imp.up"></h3>
|
||||
<div class="opts" id="impUp"></div>
|
||||
<p class="muted" style="font-size:13px;margin-top:-4px" data-i18n="imp.up.hint"></p>
|
||||
<div class="opts pics" id="impUp"></div>
|
||||
<label class="row" style="gap:8px;margin-top:12px"><span data-i18n="imp.scale"></span><input id="impScale" type="number" min="0.0001" max="10000" step="any" value="1" style="width:110px"><span class="faint" style="font-size:12.5px" data-i18n="imp.scale.hint"></span></label>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:16px">
|
||||
<button id="impClose" data-i18n="clean.close"></button>
|
||||
@@ -1408,10 +1415,19 @@ async function loadFrameGallery(refresh) {
|
||||
$("#importBtn").onclick = () => {
|
||||
let file = null;
|
||||
$("#impFile").textContent = tr("imp.none"); $("#impName").value = ""; $("#impScale").value = "1"; $("#impMake").disabled = true;
|
||||
$("#impUp").innerHTML = ["y-down", "y-up", "z-up"].map((u, i) => `<label class="opt${i ? "" : " active"}">
|
||||
<input type="radio" name="impUp" value="${u}" ${i ? "" : "checked"}><div><b>${tr("imp.up." + u)}</b><span>${tr("imp.up." + u + ".text")}</span></div></label>`).join("");
|
||||
$("#impUp").querySelectorAll("input").forEach(i => i.onchange = () =>
|
||||
$("#impUp").querySelectorAll(".opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked)));
|
||||
let shown = 0; // a newer pick wins over an older preview still on its way
|
||||
const ups = (pics, guess) => {
|
||||
const keep = document.querySelector('input[name="impUp"]:checked')?.value;
|
||||
const sel = guess || keep || "y-down";
|
||||
$("#impUp").innerHTML = ["z-up", "y-down", "y-up"].map(u => `<label class="opt${u === sel ? " active" : ""}">
|
||||
${pics === null ? "" : pics === "wait" || typeof pics === "string" ? `<div class="pic wait">${esc(pics === "wait" ? tr("imp.preview.wait") : pics)}</div>`
|
||||
: `<img class="pic" alt="" src="${pics[u]}">`}
|
||||
<div class="row" style="gap:8px;align-items:flex-start"><input type="radio" name="impUp" value="${u}" ${u === sel ? "checked" : ""}>
|
||||
<div><b>${tr("imp.up." + u)}</b>${u === guess ? `<span class="guess" style="display:inline">${tr("imp.guess")}</span>` : ""}<span>${tr("imp.up." + u + ".text")}</span></div></div></label>`).join("");
|
||||
$("#impUp").querySelectorAll("input").forEach(i => i.onchange = () =>
|
||||
$("#impUp").querySelectorAll(".opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked)));
|
||||
};
|
||||
$("#impUp").innerHTML = ""; ups(null);
|
||||
$("#impPick").onclick = async () => {
|
||||
const r = await api("/api/pick", {kind: "splat"});
|
||||
if (!r.path) return;
|
||||
@@ -1419,6 +1435,12 @@ $("#importBtn").onclick = () => {
|
||||
$("#impFile").textContent = file;
|
||||
if (!$("#impName").value) $("#impName").value = file.split("/").pop().replace(/(\.compressed)?\.[^.]+$/, "").replace(/[_-]+/g, " ");
|
||||
$("#impMake").disabled = false;
|
||||
const me = ++shown;
|
||||
ups("wait");
|
||||
const p = await api("/api/import/preview", {path: file});
|
||||
if (me !== shown) return;
|
||||
if (p.ok) ups(p.images, p.guess);
|
||||
else ups(tr("imp.preview.none", {detail: p.error ? trErr(p.error) : ""}));
|
||||
};
|
||||
$("#impClose").onclick = () => $("#importDlg").close();
|
||||
$("#impMake").onclick = async () => {
|
||||
|
||||
Reference in new issue
Block a user