0.2.3: scale for models (e.g. a model railway), for scenes and groups

Floor plan, sizes, scale bar and measuring tool in the original's size; the floor plan measures
walls and floors in the model's scale. Optionally Gaussian XR's worldScale is set when copying to
the Frame (not tested on the Frame yet). The floor plan no longer fails without walls.
13 new tests (226 in all).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
thunderheronightandClaude Opus 5.5 committed 2026-10-14 13:44:10 +02:00
1 parent fd8cd8dbb3
commit fb1c0ff515
10 files changed
+321 -24

No files matched your search

+12
View File
@@ -2,6 +2,17 @@
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.2.3] – 2026-10-14
### Added
- **Scale for models**, e.g. a model railway: set Z, N, TT, H0, S, 0, G or your own scale for a scene or a whole group (optionally passed on to its scenes). Floor plan, room sizes, the scale bar and the measuring tool then show the size of the original – a 10 cm H0 model reads as 8.7 m. The floor plan also looks for walls and floors in the model's scale, so a model is drawn like a real building.
- **Walk through a model in its original size** in Gaussian XR (option): Splat Workshop sets Gaussian XR's `worldScale` when copying a scene or tour to the Frame – the scene files on the PC stay unchanged. *Not tested on the Frame yet.*
### Fixed
- The floor plan no longer fails for scenes in which it finds no walls (e.g. a model without its scale).
## [0.2.2] – 2026-10-14
### Changed
@@ -65,6 +76,7 @@ First release.
- **Floor plans** (experimental) in two styles with a measuring tool.
- Configurable video player; English and German interface.
[0.2.3]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.2...v0.2.3
[0.2.2]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.1.0...v0.2.0
+1
View File
@@ -45,6 +45,7 @@ Alle Änderungen: [CHANGELOG.md](CHANGELOG.md) (englisch)
- **Gruppen und Rundgänge**: Szenen gruppieren (z. B. alle Räume der Wohnung) und zu einer Szene zusammensetzen – eine volle Fassung für Gaussian XR am PC und eine kleinere für die Frame. Aufnahmen aus einem Durchgang teilen das Tracking-Koordinatensystem der Frame; die Werkstatt richtet die Räume aneinander aus (Korrekturen A–D zur Wahl), legt die Grenzen zwischen den Aufnahmen in die Türen und zeigt, wie gut die Räume aneinanderpassen. Vor dem Start schätzt sie den Speicherbedarf.
<br><img src="docs/combine.png" width="360" alt="Dialog „Zusammensetzen“: Korrekturen A bis D, Grenzen zwischen den Aufnahmen, Größe der Frame-Fassung, volle Fassung, Speicherschätzung">
<br><sub>Beispieldaten (englische Oberfläche) – Raumnamen und Vorschaubilder sind Platzhalter.</sub>
- **Maßstab für Modelle** wie eine Modelleisenbahn (Z, N, TT, H0, S, 0, G oder eigener): Grundriss, Größen und Messwerkzeug zeigen die Größe des Originals, und auf Wunsch zeigt Gaussian XR das Modell begehbar in Originalgröße *(an der Frame noch nicht getestet)*.
- **Grundrisse** *(experimentell)* einzelner Szenen oder einer ganzen Gruppe – Aufnahmen aus einem Durchgang fügen sich von selbst zum Grundriss der ganzen Wohnung. Zwei Stile: *Hübsch* (Draufsicht in Farbe, Wände, gelaufener Weg) und *Klassisch* (leuchtende Punktdichte), dazu ein einfaches Messwerkzeug (Punkte anklicken, Abstände ablesen – etwa ±5 cm genau).
- **Fertige Szenen**: später auf die Frame übertragen, erneut übertragen, umbenennen (Ordner und Namensschild ziehen mit), löschen (in den Papierkorb), Ordner oder Spirula Studio öffnen.
- **Abgebrochene Läufe** werden mit ihrem Stand angezeigt: fortsetzen, neu starten oder löschen.
+1
View File
@@ -47,6 +47,7 @@ All changes: [CHANGELOG.md](CHANGELOG.md)
- **Groups and tours**: group scenes (e.g. all rooms of your home) and put them together into one scene – a full version for Gaussian XR on the PC and a smaller one for the Frame. Recordings from one session share the Frame's tracking frame; Splat Workshop fits the rooms to each other (corrections A–D to choose), lets the borders between recordings run through doors and shows how well the rooms meet. Before starting it estimates the memory needed.
<br><img src="docs/combine.png" width="360" alt="Dialog “Put together”: corrections A to D, borders between the recordings, size of the Frame version, full version, memory estimate">
<br><sub>Example data – room names and preview images are placeholders.</sub>
- **Scale for models** like a model railway (Z, N, TT, H0, S, 0, G or your own): floor plan, sizes and the measuring tool show the original's size, and optionally Gaussian XR shows the model in its original size to walk through *(not tested on the Frame yet)*.
- **Floor plans** *(experimental)* of single scenes or a whole group – recordings from one session line up by themselves into a plan of your whole home. Two styles: *Pretty* (colour top view, walls, the path you walked) and *Classic* (glowing point density), plus a simple measuring tool (click points, get distances – about ±5 cm).
<br><img src="docs/floorplan.png" width="640" alt="Combined floor plan of an example home in the classic style, with the measuring tool">
<br><sub>Example home – synthetic data, drawn with the same renderer. Combining rooms relies on the Frame keeping its tracking frame between recordings; how well that holds for a whole home is still being tested.</sub>
+15
View File
@@ -12,6 +12,7 @@
# FULL_NAME="..." name plate of the full version (default: "<name> (full)")
# PLAN_COMMA=1 decimal comma on the floor plan used as preview
# NO_UPLOAD=1 only compute, don't copy to the Frame
# WORLD_SCALE=87 show a model in Gaussian XR in the size of the original (1:87)
# A previous result of the same name stays until the new one is done.
#
# Lines starting with "@@" are machine-readable progress markers for the web interface.
@@ -38,6 +39,19 @@ tmp=$work/$slug
stem=$(printf '%s' "$slug" | tr 'A-Z' 'a-z')
frame_splats=${FRAME_SPLATS:-3500000}
# Gaussian XR's worldScale in a scene's transform file, when WORLD_SCALE is set (a model shown in the
# size of the original, e.g. 87 for H0)
world_scale() {
[ -n "${WORLD_SCALE:-}" ] || return 0
python3 - "$1" "$WORLD_SCALE" <<'PY'
import json, sys
path, scale = sys.argv[1], float(sys.argv[2])
data = json.load(open(path))
data["worldScale"] = scale
json.dump(data, open(path, "w"), indent=2)
PY
}
main() {
rm -rf "$tmp"
mkdir -p "$tmp"
@@ -74,6 +88,7 @@ main() {
uv run -q "$HERE/make_gallery.py" --plan "$tmp/plan.png" "$tmp/full" "${FULL_NAME:-$name (full)}" "$stem-full"
cp "$tmp/full/scene.sog" "$tmp/full/$stem-full.sog"
cp "$tmp/full/scene.xrSceneTransform.json" "$tmp/full/$stem-full.xrSceneTransform.json"
world_scale "$tmp/full/$stem-full.xrSceneTransform.json"
fi
# Replace the previous result only now
+38 -20
View File
@@ -101,9 +101,11 @@ def heights(y):
return np.percentile(y, 97), np.percentile(y, 3)
def wall_angle(points_xz):
def wall_angle(points_xz, bin_size=0.03):
"""Yaw that makes the walls axis-aligned: maximise the peakiness of the x/z histograms."""
best, best_score = 0.0, -1
if len(points_xz) < 50: # no walls found: leave it as it is
return best
for deg in np.arange(0, 90, 0.5):
a = np.radians(deg)
c, s = np.cos(a), np.sin(a)
@@ -111,7 +113,7 @@ def wall_angle(points_xz):
v = points_xz[:, 0] * s + points_xz[:, 1] * c
score = 0
for w in (u, v):
h, _ = np.histogram(w, bins=max(10, int((w.max() - w.min()) / 0.03)))
h, _ = np.histogram(w, bins=max(10, int((w.max() - w.min()) / bin_size)))
score += float(np.sum(h.astype(np.float64) ** 2)) / len(w) ** 2
if score > best_score:
best, best_score = a, score
@@ -142,11 +144,16 @@ def main():
ap.add_argument("runs", nargs="+", help="run folder, optionally =label")
ap.add_argument("--title", default="")
ap.add_argument("--comma", action="store_true", help="decimal comma in sizes")
ap.add_argument("--ratio", type=float, default=1.0,
help="model scale, e.g. 87 for H0 (1:87): sizes and the scale bar show the original's size")
ap.add_argument("--style", choices=("pretty", "classic"), default="pretty",
help="pretty: colour top view, glowing walls, camera path; classic: glowing point density only")
args = ap.parse_args()
out = Path(args.out)
rng = np.random.default_rng(1)
# All lengths below are in metres of the original: a model (--ratio 87) is measured in its own scale,
# so its walls are looked for 1 to 2 cm above its floor instead of 0.9 to 1.9 m.
m = 1 / args.ratio
scenes = []
for arg in args.runs:
run, _, label = arg.partition("=")
@@ -155,23 +162,25 @@ def main():
floor, ceil = heights(xyz[:, 1])
# keep the room: below the ceiling, not below the floor, and drop far-away floaters
lo, hi = np.percentile(xyz[:, [0, 2]], 0.5, axis=0), np.percentile(xyz[:, [0, 2]], 99.5, axis=0)
inside = ((xyz[:, 1] > ceil + 0.25) & (xyz[:, 1] < floor + 0.15)
& np.all(xyz[:, [0, 2]] >= lo - 0.3, axis=1) & np.all(xyz[:, [0, 2]] <= hi + 0.3, axis=1))
up = floor - xyz[:, 1] # height above the floor in metres
walls = inside & (up > 0.9) & (up < 1.9)
inside = ((xyz[:, 1] > ceil + 0.25 * m) & (xyz[:, 1] < floor + 0.15 * m)
& np.all(xyz[:, [0, 2]] >= lo - 0.3 * m, axis=1) & np.all(xyz[:, [0, 2]] <= hi + 0.3 * m, axis=1))
if inside.sum() < 100: # e.g. a model drawn without its scale: keep all
inside = np.ones(len(xyz), bool)
up = floor - xyz[:, 1] # height above the floor
walls = inside & (up > 0.9 * m) & (up < 1.9 * m)
scenes.append({"label": label or run.name, "xyz": xyz[inside], "rgb": rgb[inside], "up": up[inside],
"walls": xyz[walls][:, [0, 2]], "cams": load_cameras(run), "floor": floor})
all_walls = np.vstack([s["walls"] for s in scenes])
sub = all_walls[rng.choice(len(all_walls), min(len(all_walls), 150_000), replace=False)]
angle = wall_angle(sub)
angle = wall_angle(sub, 0.03 * m)
for s in scenes:
s["p"] = rotate(s["xyz"][:, [0, 2]], angle)
s["w"] = rotate(s["walls"], angle)
s["c"] = rotate(s["cams"][:, [0, 2]], angle) if s["cams"] is not None else None
s["box"] = room_box(s["w"])
s["box"] = room_box(s["w"], 0.05 * m)
if s["box"] is not None: # keep only this room (plus a margin)
b0, b1 = s["box"][0] - 0.35, s["box"][1] + 0.35
b0, b1 = s["box"][0] - 0.35 * m, s["box"][1] + 0.35 * m
ok = np.all((s["p"] >= b0) & (s["p"] <= b1), axis=1)
s["p"], s["rgb"], s["up"] = s["p"][ok], s["rgb"][ok], s["up"][ok]
okw = np.all((s["w"] >= b0) & (s["w"] <= b1), axis=1)
@@ -179,11 +188,11 @@ def main():
boxes = [s["box"] for s in scenes if s["box"] is not None]
if boxes:
lo = np.min([b[0] for b in boxes], axis=0) - 0.8
hi = np.max([b[1] for b in boxes], axis=0) + 0.8
lo = np.min([b[0] for b in boxes], axis=0) - 0.8 * m
hi = np.max([b[1] for b in boxes], axis=0) + 0.8 * m
else:
allp = np.vstack([s["p"] for s in scenes])
lo, hi = np.percentile(allp, 1, axis=0) - 0.5, np.percentile(allp, 99, axis=0) + 0.5
lo, hi = np.percentile(allp, 1, axis=0) - 0.5 * m, np.percentile(allp, 99, axis=0) + 0.5 * m
span = hi - lo
ppm = (SIZE - 160) / span.max() # pixels per metre
W, H = int(span[0] * ppm) + 160, int(span[1] * ppm) + 200
@@ -212,23 +221,32 @@ def main():
if s["box"] is not None:
(bx0, bz0), (bx1, bz1) = s["box"]
cx, cz = px(np.array([[(bx0 + bx1) / 2, (bz0 + bz1) / 2]]))
size = f"~ {bx1 - bx0:.1f} × {bz1 - bz0:.1f} m".replace(".", "," if args.comma else ".")
size = f"~ {(bx1 - bx0) * args.ratio:.1f} × {(bz1 - bz0) * args.ratio:.1f} m".replace(".", "," if args.comma else ".")
for txt, fnt, dy in ((s["label"], f_label, -16), (size, f_small, 16)):
tw = d.textlength(txt, font=fnt)
d.text((cx[0] - tw / 2 + 1, cz[0] + dy - 13 + 1), txt, font=fnt, fill=(0, 0, 0))
d.text((cx[0] - tw / 2, cz[0] + dy - 13), txt, font=fnt, fill=(255, 255, 255) if dy < 0 else (210, 214, 224))
# scale bar (1 m) and title
d.line((W - 60 - ppm, H - 40, W - 60, H - 40), fill=(230, 230, 230), width=3)
for xx in (W - 60 - ppm, W - 60):
# scale bar: a round length of the original (for a model: of the real thing), about 150 px long
real_ppm = ppm / args.ratio # pixels per metre of the original
length = min((0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000), key=lambda l: abs(l * real_ppm - 150))
bar = length * real_ppm
text = (f"{length * 100:g} cm" if length < 1 else f"{length:g} m")
d.line((W - 60 - bar, H - 40, W - 60, H - 40), fill=(230, 230, 230), width=3)
for xx in (W - 60 - bar, W - 60):
d.line((xx, H - 47, xx, H - 33), fill=(230, 230, 230), width=3)
d.text((W - 60 - ppm / 2 - 12, H - 72), "1 m", font=f_small, fill=(230, 230, 230))
tw = d.textlength(text, font=f_small)
d.text((W - 60 - bar / 2 - tw / 2, H - 72), text, font=f_small, fill=(230, 230, 230))
if args.ratio != 1:
ratio = f"1:{args.ratio:g}".replace(".", "," if args.comma else ".")
d.text((W - 60 - bar - 20 - d.textlength(ratio, font=f_small), H - 50), ratio, font=f_small, fill=(230, 230, 230))
if args.title:
d.text((40, 32), args.title, font=f_title, fill=(235, 238, 245))
out_img.save(out)
info = [{"label": s["label"], "size": [round(float(v), 2) for v in (s["box"][1] - s["box"][0])] if s["box"] is not None else None}
for s in scenes]
print(json.dumps({"angle": round(float(np.degrees(angle)), 1), "scenes": info, "pixels_per_metre": round(ppm, 1)}))
info = [{"label": s["label"], "size": [round(float(v) * args.ratio, 2) for v in (s["box"][1] - s["box"][0])]
if s["box"] is not None else None} for s in scenes]
print(json.dumps({"angle": round(float(np.degrees(angle)), 1), "scenes": info, "ratio": args.ratio,
"pixels_per_metre": round(real_ppm, 4)}))
def classic(scenes, px, W, H):
+14
View File
@@ -29,10 +29,24 @@ log=${LOG:-$RUNS_DIR/$slug.log}
[ -f "$log" ] || log=$(grep -ls "Gaussian XR initial view" "$RUNS_DIR"/*.log 2>/dev/null | xargs -r grep -l "$out/" | head -1)
[ -f "$log" ] || { echo "No pipeline log found for $slug (set LOG=...)" >&2; exit 1; }
# Gaussian XR's worldScale in a scene's transform file, when WORLD_SCALE is set (a model shown in the
# size of the original, e.g. 87 for H0)
world_scale() {
[ -n "${WORLD_SCALE:-}" ] || return 0
python3 - "$1" "$WORLD_SCALE" <<'PY'
import json, sys
path, scale = sys.argv[1], float(sys.argv[2])
data = json.load(open(path))
data["worldScale"] = scale
json.dump(data, open(path, "w"), indent=2)
PY
}
# Gallery files: <stem>.sog / .xrSceneTransform.json / .gallery.png / .sceneinfo.png
stem=${STEM:-$(printf '%s' "$slug" | tr 'A-Z' 'a-z')}
cp "$out/scene.sog" "$out/$stem.sog"
cp "$out/scene.xrSceneTransform.json" "$out/$stem.xrSceneTransform.json"
world_scale "$out/$stem.xrSceneTransform.json"
uv run -q "$HERE/make_gallery.py" "$out" "$log" "$name" "$stem"
[ "${GALLERY_ONLY:-0}" = 1 ] && exit 0
+48 -3
View File
@@ -29,7 +29,7 @@ from pathlib import Path
from . import check, config
from .i18n import T, detect
VERSION = "0.2.2"
VERSION = "0.2.3"
CFG = config.load()
ROOT = config.ROOT
WEB = ROOT / "web"
@@ -665,6 +665,40 @@ def watcher():
# ---------------------------------------------------------------- copy to the Frame
# ---------------------------------------------------------------- scale (models, e.g. a model railway)
SCALE_MAX = 1000 # 1:1000 – further than any model railway or building model
def world_scale_env(meta: dict) -> dict:
"""WORLD_SCALE for to-frame.sh/combine.sh: show a model in Gaussian XR in the size of the original."""
scale = meta.get("scale") or 1
return {"WORLD_SCALE": f"{scale:g}"} if meta.get("scale_xr") and scale != 1 else {}
def set_scale(slug: str, ratio, xr: bool, members: bool, update_frame: bool):
"""Scale 1:ratio of a scene or group (1 = original size). For a group optionally also for its scenes."""
try:
ratio = float(ratio)
except (TypeError, ValueError):
return err("err.bad_value", key="scale")
if not 1 <= ratio <= SCALE_MAX or ratio != ratio:
return err("err.range", key="scale", lo=1, hi=SCALE_MAX)
meta = read_meta(slug) if slug and Path(slug).name == slug and not slug.startswith(".") else {}
group = is_group(meta)
if not group and not (scene_dir(slug) and (RUNS / slug / "scene.sog").exists()):
return err("err.no_scene")
targets = [slug] + ([m for m in meta.get("members", []) if scene_dir(m)] if group and members else [])
if any(scene_busy(t_) for t_ in targets):
return err("err.scene_busy")
xr = bool(xr) and ratio != 1
for t_ in targets:
write_meta(t_, scale=ratio if ratio != 1 else None, scale_xr=xr)
if update_frame and FRAME and (RUNS / slug / "scene.sog").exists():
return start_upload(slug) # the Frame gets the new worldScale
return None
def start_upload(slug: str):
if not FRAME:
return err("err.no_frame")
@@ -680,6 +714,7 @@ def start_upload(slug: str):
for key, var in (("frame_folder", "TARGET"), ("stem", "STEM")):
if meta.get(key):
env[var] = meta[key]
env.update(world_scale_env(meta))
if meta.get("log"):
env["LOG"] = str(RUNS / Path(meta["log"]).name)
JOBS.mkdir(parents=True, exist_ok=True)
@@ -798,7 +833,8 @@ def scenes():
"recording": meta.get("recording"), "size": sog.stat().st_size,
"mtime": int(sog.stat().st_mtime), "gaussians": gauss, "preview": bool(gallery),
"ply": (d / "spirula" / "scene.ply").exists(), "on_frame": on_frame, "disk": disk_usage(d),
"upload": upload_status(slug), "replacing": replacing})
"upload": upload_status(slug), "replacing": replacing,
"scale": meta.get("scale") or 1, "scale_xr": bool(meta.get("scale_xr"))})
return out
@@ -820,9 +856,11 @@ def floorplan(slugs: list[str], style: str, lang: str, title: str):
runs.append((d, read_meta(slug).get("name") or slug, ply.stat().st_mtime))
if not runs or len(runs) > 12:
return err("err.bad_request")
scales = {read_meta(slug).get("scale") or 1 for slug in dict.fromkeys(slugs)}
ratio = scales.pop() if len(scales) == 1 else 1
title = title.strip()[:60]
script = (SCRIPTS / "floorplan.py").stat().st_mtime # a new version of the script draws anew
key = hashlib.sha1(json.dumps([[str(r[0]), r[1], r[2]] for r in runs] + [style, lang, title, script]).encode()).hexdigest()[:16]
key = hashlib.sha1(json.dumps([[str(r[0]), r[1], r[2]] for r in runs] + [style, lang, title, script, ratio]).encode()).hexdigest()[:16]
out = CACHE / f"plan-{key}.png"
meta = out.with_suffix(".json")
with lock:
@@ -836,6 +874,8 @@ def floorplan(slugs: list[str], style: str, lang: str, title: str):
cmd += ["--title", title]
if lang == "de":
cmd.append("--comma")
if ratio != 1:
cmd += ["--ratio", str(ratio)]
if not plan_slots.acquire(timeout=1):
return err("err.plan_busy")
try:
@@ -1221,6 +1261,7 @@ def groups():
"on_frame": on_frame, "upload": upload_status(slug), "disk": disk_usage(d)}
out.append({"slug": slug, "name": meta.get("name") or slug, "members": members, "result": res,
"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")),
"splats": sum(scene_list[m].get("gaussians") or 0 for m in members)})
return {"groups": out, "suggestion": suggestion(scene_list, out), "mem_available_gb": mem_available()}
@@ -1332,6 +1373,7 @@ def start_combine(p: dict):
env["PLAN_COMMA"] = "1"
if meta.get("frame_folder"):
env["TARGET"] = meta["frame_folder"]
env.update(world_scale_env(meta))
upload = bool(p.get("upload")) and bool(FRAME)
if not upload:
env["NO_UPLOAD"] = "1"
@@ -1563,6 +1605,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
e = save_group(p.get("slug", ""), p.get("name", ""), p.get("members", ""))
if isinstance(e, str):
return self.send(200, {"ok": True, "slug": e})
elif path == "/api/scale":
e = set_scale(p.get("slug", ""), p.get("scale"), bool(p.get("xr")), bool(p.get("with_scenes")),
bool(p.get("update_frame")))
elif path == "/api/group/dismiss":
st = load_state()
st["dismissed_groups"] = sorted(set(st.get("dismissed_groups", [])) | {p.get("key", "")})[-50:]
+125
View File
@@ -0,0 +1,125 @@
"""Scale for models (e.g. H0 = 1:87): floor plan and measurements in the original's size, and the
original's size in Gaussian XR (worldScale).
The model is the synthetic 4 x 3 m room built 87 times smaller, so the expected numbers are simply
those of the full-size room."""
import json
import subprocess
import sys
import time
import pytest
from conftest import BASE, ROOT, RUNS, server
import synth
H0 = 87
def plan(*runs, ratio=None):
out = runs[0].parent / "plan.png"
cmd = [sys.executable, str(ROOT / "scripts" / "floorplan.py"), str(out), *[f"{r}={r.name}" for r in runs]]
if ratio:
cmd += ["--ratio", str(ratio)]
r = subprocess.run(cmd, capture_output=True, text=True)
assert r.returncode == 0, r.stderr
return json.loads(r.stdout.strip().splitlines()[-1])
def test_a_model_has_the_floor_plan_of_the_original():
full = plan(synth.make_room(BASE / "full", "Room", "A", error=(0.0, (0, 0, 0))))
model = plan(synth.make_room(BASE / "model", "Room", "A", error=(0.0, (0, 0, 0)), scale=1 / H0), ratio=H0)
assert model["scenes"][0]["size"] == pytest.approx(full["scenes"][0]["size"], abs=0.05) # 4 x 3 m
assert model["pixels_per_metre"] == pytest.approx(full["pixels_per_metre"], rel=0.01) # measuring in metres
assert model["ratio"] == H0
def test_a_model_without_its_scale_still_draws():
info = plan(synth.make_room(BASE / "model", "Room", "A", error=(0.0, (0, 0, 0)), scale=1 / H0))
assert info["ratio"] == 1
@pytest.mark.parametrize("scale", ["0.5", "2000", "abc", "nan"])
def test_scales_out_of_range_are_refused(api, scale):
synth.make_room(RUNS, "Layout", "A")
status, r = api.post("/api/scale", {"slug": "Layout", "scale": scale})
assert status == 400 and r["error"]["code"] in ("err.range", "err.bad_value")
def test_setting_and_clearing_the_scale_of_a_scene(api):
synth.make_room(RUNS, "Layout", "A")
assert api.post("/api/scale", {"slug": "Layout", "scale": "87", "xr": True})[0] == 200
s = {x["slug"]: x for x in api.get("/api/scenes")[1]}["Layout"]
assert (s["scale"], s["scale_xr"]) == (87, True)
assert api.post("/api/scale", {"slug": "Layout", "scale": "1", "xr": True})[0] == 200
s = {x["slug"]: x for x in api.get("/api/scenes")[1]}["Layout"]
assert (s["scale"], s["scale_xr"]) == (1, False) # original size: nothing to enlarge in XR
assert api.post("/api/scale", {"slug": "Nope", "scale": "87"})[0] == 400
def test_a_group_passes_its_scale_on(api):
synth.make_room(RUNS, "Station", "A", recording="2026-10-12_16-40-00_stereo.mp4")
synth.make_room(RUNS, "Yard", "B", recording="2026-10-12_16-43-00_stereo.mp4")
synth.make_room(RUNS, "Other", "A", recording="2026-10-03_19-00-00_stereo.mp4")
server.save_group("", "Layout", "Station,Yard")
assert api.post("/api/scale", {"slug": "Layout", "scale": "160", "with_scenes": True})[0] == 200
scales = {x["slug"]: x["scale"] for x in api.get("/api/scenes")[1]}
assert scales == {"Station": 160, "Yard": 160, "Other": 1}
assert api.get("/api/groups")[1]["groups"][0]["scale"] == 160
api.post("/api/scale", {"slug": "Layout", "scale": "87"}) # without its scenes this time
assert {x["slug"]: x["scale"] for x in api.get("/api/scenes")[1]}["Station"] == 160
def test_the_floor_plan_endpoint_measures_in_the_originals_metres(api):
synth.make_room(RUNS, "Layout", "A", error=(0.0, (0, 0, 0)), scale=1 / H0)
api.post("/api/scale", {"slug": "Layout", "scale": str(H0)})
status, png = api.get("/api/floorplan?slugs=Layout&style=classic")
assert status == 200 and png[:4] == b"\x89PNG"
full = plan(synth.make_room(BASE / "full", "Room", "A", error=(0.0, (0, 0, 0))))
info = server.floorplan(["Layout"], "classic", "en", "")[1]
assert info["pixels_per_metre"] == pytest.approx(full["pixels_per_metre"], rel=0.01)
def wait_upload(slug):
end = time.time() + 120
while time.time() < end:
u = server.upload_status(slug)
if u and not u["running"]:
return u
time.sleep(0.2)
raise TimeoutError
@pytest.mark.parametrize("xr,expected", [(True, H0), (False, 1.0)])
def test_world_scale_on_the_frame(api, frame, xr, expected):
run = synth.make_room(RUNS, "Layout", "A")
api.post("/api/scale", {"slug": "Layout", "scale": str(H0), "xr": xr})
api.post("/api/upload", {"slug": "Layout"})
assert wait_upload("Layout")["ok"]
sent = json.loads((frame / server.FRAME_SCENES / "20_Layout" / "layout.xrSceneTransform.json").read_text())
assert sent["worldScale"] == expected
assert sent["transform"] == json.loads((run / "scene.xrSceneTransform.json").read_text())["transform"]
assert json.loads((run / "scene.xrSceneTransform.json").read_text())["worldScale"] == 1.0 # the original stays
def test_changing_the_scale_updates_the_frame(api, frame):
synth.make_room(RUNS, "Layout", "A")
api.post("/api/upload", {"slug": "Layout"})
wait_upload("Layout")
assert api.post("/api/scale", {"slug": "Layout", "scale": "45", "xr": True, "update_frame": True})[0] == 200
assert wait_upload("Layout")["ok"]
sent = json.loads((frame / server.FRAME_SCENES / "20_Layout" / "layout.xrSceneTransform.json").read_text())
assert sent["worldScale"] == 45
def test_a_tour_of_a_model_is_walkable_in_both_versions():
synth.make_room(RUNS, "Station", "A", recording="2026-10-12_16-40-00_stereo.mp4")
synth.make_room(RUNS, "Yard", "B", recording="2026-10-12_16-43-00_stereo.mp4")
server.save_group("", "Layout", "Station,Yard")
server.set_scale("Layout", H0, xr=True, members=True, update_frame=False)
assert server.start_combine({"slug": "Layout", "full": True}) is None
from conftest import wait_job
assert wait_job()["result"] == "ok"
tour = RUNS / "Layout"
assert json.loads((tour / "layout.xrSceneTransform.json").read_text())["worldScale"] == H0
assert json.loads((tour / "full" / "layout-full.xrSceneTransform.json").read_text())["worldScale"] == H0
+12
View File
@@ -184,6 +184,12 @@ const I18N = {
"job.sub.combine": "{n} scenes · correction {align} · {target} · for {time}",
"err.in_other_group": "“{group}” already contains one of these scenes",
"err.combine_too_few": "Putting together needs at least two finished scenes",
"scale.button.title": "Scale – for models, e.g. a model railway", "scale.title": "Scale of “{name}”",
"scale.hint": "For models: floor plan, sizes and measurements then show the size of the original (H0: 10 cm in the model = 8.7 m).",
"scale.original": "Original size", "scale.custom": "Own scale",
"scale.xr": "Walk through it in Gaussian XR in the original's size (not tested on the Frame yet)",
"scale.members": "Also for all scenes in this group", "scale.frame": "Update it on the Frame too",
"scale.line": "Scale {ratio}", "scale.line.xr": "original size in XR", "scale.done": "Scale saved",
"err.plan_failed": "The floor plan could not be drawn", "sc.replacing": "Being computed again – stays until the new one is done", "err.plan_busy": "Other floor plans are being drawn right now, try again in a moment",
"plan.measure": "📏 Measure", "plan.measure.title": "Measure distances in the floor plan",
"plan.measure.hint": "Click to set points · right-click removes the last one · horizontal distances, about ±5 cm",
@@ -377,6 +383,12 @@ const I18N = {
"job.sub.combine": "{n} Szenen · Korrektur {align} · {target} · seit {time}",
"err.in_other_group": "„{group}“ enthält schon eine dieser Szenen",
"err.combine_too_few": "Zum Zusammensetzen braucht es mindestens zwei fertige Szenen",
"scale.button.title": "Maßstab – für Modelle, z. B. eine Modelleisenbahn", "scale.title": "Maßstab von „{name}“",
"scale.hint": "Für Modelle: Grundriss, Größen und Messwerte zeigen dann die Größe des Originals (H0: 10 cm im Modell = 8,7 m).",
"scale.original": "Originalgröße", "scale.custom": "Eigener Maßstab",
"scale.xr": "In Gaussian XR in Originalgröße begehbar (an der Frame noch nicht getestet)",
"scale.members": "Auch für alle Szenen dieser Gruppe", "scale.frame": "Auch auf der Frame aktualisieren",
"scale.line": "Maßstab {ratio}", "scale.line.xr": "in XR in Originalgröße", "scale.done": "Maßstab gespeichert",
"err.plan_failed": "Der Grundriss konnte nicht gezeichnet werden", "sc.replacing": "Wird neu gerechnet – bleibt, bis die neue fertig ist", "err.plan_busy": "Gerade werden andere Grundrisse gezeichnet, gleich noch einmal versuchen",
"plan.measure": "📏 Messen", "plan.measure.title": "Abstände im Grundriss messen",
"plan.measure.hint": "Klicken setzt Punkte · Rechtsklick nimmt den letzten zurück · waagerechte Abstände, etwa ±5 cm genau",
+55 -1
View File
@@ -372,6 +372,20 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
</div>
</dialog>
<dialog id="scaleDlg">
<h2 style="margin-top:0" id="scaleTitle"></h2>
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="scale.hint"></p>
<div class="opts" id="scaleOpts"></div>
<label class="row" style="margin-top:10px;gap:8px;font-size:13.5px">1 : <input id="scaleCustom" type="number" min="1" max="1000" step="any" style="width:110px"></label>
<label class="row" id="scaleXrRow" style="flex-wrap:nowrap;align-items:flex-start;margin-top:12px;gap:8px;font-size:13.5px"><input type="checkbox" id="scaleXr" style="margin-top:3px"> <span data-i18n="scale.xr"></span></label>
<label class="row hidden" id="scaleMembersRow" style="flex-wrap:nowrap;align-items:flex-start;margin-top:8px;gap:8px;font-size:13.5px"><input type="checkbox" id="scaleMembers" checked> <span data-i18n="scale.members"></span></label>
<label class="row hidden" id="scaleFrameRow" style="flex-wrap:nowrap;align-items:flex-start;margin-top:8px;gap:8px;font-size:13.5px"><input type="checkbox" id="scaleFrame" checked> <span data-i18n="scale.frame"></span></label>
<div class="row" style="justify-content:flex-end;margin-top:16px">
<button id="scaleCancel" data-i18n="grp.cancel"></button>
<button class="primary" id="scaleOk" data-i18n="grp.save"></button>
</div>
</dialog>
<dialog id="renDlg">
<h2 style="margin-top:0" id="renTitle"></h2>
<p class="muted" id="renHint" style="font-size:13px"></p>
@@ -821,8 +835,9 @@ function sceneCard(s) {
<div class="meta"><b>${esc(s.name)}</b>
<span>${new Date(s.mtime * 1000).toLocaleDateString(LANG)} · ${s.gaussians ? tr("sc.splats", {n: num(s.gaussians / 1e6, 2)}) + " · " : ""}${mb(s.size)}</span>
${s.disk ? `<span class="faint">${tr("sc.disk", {size: mb(s.disk.total)})}</span>` : ""}
${scaleLine(s)}
${s.replacing ? `<div class="fstate faint">${tr("sc.replacing")}</div>` : frameState(s)}
<div class="acts${s.replacing ? " hidden" : ""}">${uploadBtn(s)}${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>${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}<button class="small" data-rename="${esc(s.slug)}">${tr("ren.button")}</button><button class="small" data-clean="${esc(s.slug)}" title="${tr("sc.clean.title")}" aria-label="${tr("sc.clean.title")}">${tr("sc.clean")}</button><button class="small" data-delete="${esc(s.slug)}" title="${tr("sc.delete.title")}" aria-label="${tr("sc.delete.title")}">${tr("sc.delete")}</button></div>
<div class="acts${s.replacing ? " hidden" : ""}">${uploadBtn(s)}${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>${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}<button class="small" data-rename="${esc(s.slug)}">${tr("ren.button")}</button><button class="small" data-scale="${esc(s.slug)}" title="${tr("scale.button.title")}">📏${s.scale !== 1 ? " " + ratioText(s.scale) : ""}</button><button class="small" data-clean="${esc(s.slug)}" title="${tr("sc.clean.title")}" aria-label="${tr("sc.clean.title")}">${tr("sc.clean")}</button><button class="small" data-delete="${esc(s.slug)}" title="${tr("sc.delete.title")}" aria-label="${tr("sc.delete.title")}">${tr("sc.delete")}</button></div>
</div></div>`;
}
const ALIGN_LETTER = {frame: "A", images: "B", shift: "C", rigid: "D"};
@@ -854,6 +869,7 @@ function groupBlock(g, bySlug) {
<div class="gthumb" ${thumb}>${r && r.preview ? "" : "📁"}</div>
<div class="gmeta"><b>📁 ${esc(g.name)}</b>
<span>${tr("grp.rooms", {n: rooms.length})} · ${tr("sc.disk", {size: mb(disk)})}</span>
${scaleLine(g)}
${tour}${busy}
<div class="acts">
<button class="small" data-gtoggle="${esc(g.slug)}" aria-expanded="${open}">${tr(open ? "grp.hide" : "grp.show", {n: rooms.length})}</button>
@@ -863,6 +879,7 @@ function groupBlock(g, bySlug) {
${r && r.ply ? `<button class="small pc-only" data-spirula="${esc(g.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}
${r ? `<button class="small pc-only" data-open="${esc(g.slug)}">${tr("sc.folder")}</button>` : ""}
<button class="small" data-gedit="${esc(g.slug)}">${tr("grp.edit")}</button>
<button class="small" data-scale="${esc(g.slug)}" title="${tr("scale.button.title")}" ${g.busy ? "disabled" : ""}>📏${g.scale !== 1 ? " " + ratioText(g.scale) : ""}</button>
<button class="small" data-clean="${esc(g.slug)}" title="${tr("grp.clean.title")}" aria-label="${tr("grp.clean.title")}" ${g.busy ? "disabled" : ""}>${tr("sc.clean")}</button>
<button class="small" data-gdelete="${esc(g.slug)}" title="${tr("grp.delete.title")}" aria-label="${tr("grp.delete.title")}">${tr("sc.delete")}</button>
</div>
@@ -894,6 +911,7 @@ async function loadScenes() {
$("#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-scale]").forEach(b => b.onclick = () => openScale(b.dataset.scale));
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)));
@@ -1011,6 +1029,42 @@ function openCombine(g) {
};
}
/* ---------- scale (models) ---------- */
const GAUGES = [[1, "scale.original"], [220, "Z"], [160, "N"], [120, "TT"], [87, "H0"], [64, "S"], [45, "0"], [22.5, "G"]];
function ratioText(r) { return "1:" + num(r, r % 1 ? 1 : 0); }
function scaleLine(x) {
if (!x.scale || x.scale === 1) return "";
return `<span class="faint">${tr("scale.line", {ratio: ratioText(x.scale)})}${x.scale_xr ? " · " + tr("scale.line.xr") : ""}</span>`;
}
function openScale(slug) {
const g = (S.groups || []).find(x => x.slug === slug), x = g || S.scenes.find(y => y.slug === slug);
if (!x) return;
const cur = x.scale || 1, known = GAUGES.some(([r]) => r === cur);
$("#scaleTitle").textContent = tr("scale.title", {name: x.name});
$("#scaleOpts").innerHTML = GAUGES.map(([r, label]) => `<label class="opt${r === cur ? " active" : ""}">
<input type="radio" name="scaleR" value="${r}" ${r === cur ? "checked" : ""}><div><b>${r === 1 ? tr(label) : label} · ${ratioText(r)}</b></div></label>`).join("")
+ `<label class="opt${known ? "" : " active"}"><input type="radio" name="scaleR" value="custom" ${known ? "" : "checked"}><div><b>${tr("scale.custom")}</b></div></label>`;
$("#scaleCustom").value = known ? "" : cur;
const value = () => { const v = $("input[name=scaleR]:checked").value; return v === "custom" ? parseFloat($("#scaleCustom").value) : parseFloat(v); };
const sync = () => { $("#scaleOpts").querySelectorAll(".opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked));
const one = value() === 1; $("#scaleXr").disabled = one; if (one) $("#scaleXr").checked = false; };
$("#scaleOpts").querySelectorAll("input").forEach(i => i.onchange = sync);
$("#scaleCustom").oninput = () => { $("input[name=scaleR][value=custom]").checked = true; sync(); };
$("#scaleXr").checked = !!x.scale_xr;
const onFrame = g ? g.result && g.result.on_frame : x.on_frame;
$("#scaleMembersRow").classList.toggle("hidden", !g);
$("#scaleFrameRow").classList.toggle("hidden", !(S.frameConfigured && onFrame));
sync();
$("#scaleDlg").showModal();
$("#scaleCancel").onclick = () => $("#scaleDlg").close();
$("#scaleOk").onclick = async () => {
const r = await api("/api/scale", {slug, scale: String(value()), xr: $("#scaleXr").checked, with_scenes: !!g && $("#scaleMembers").checked,
update_frame: !$("#scaleFrameRow").classList.contains("hidden") && $("#scaleFrame").checked});
if (!r.ok) { toast(trErr(r.error)); return; }
$("#scaleDlg").close(); toast(tr("scale.done")); loadScenes();
};
}
/* ---------- renaming ---------- */
function askName(title, hint, value, frameOption) {
$("#renTitle").textContent = title; $("#renHint").textContent = hint;