From fb1c0ff515f09f1ec6de6ebbd9a84233edc252bb Mon Sep 17 00:00:00 2001 From: thunderheronight Date: Wed, 14 Oct 2026 13:44:10 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 12 ++++ README.de.md | 1 + README.md | 1 + scripts/combine.sh | 15 +++++ scripts/floorplan.py | 58 +++++++++++------- scripts/to-frame.sh | 14 +++++ splat_workshop/server.py | 51 +++++++++++++++- tests/test_scale.py | 125 +++++++++++++++++++++++++++++++++++++++ web/i18n.js | 12 ++++ web/index.html | 56 +++++++++++++++++- 10 files changed, 321 insertions(+), 24 deletions(-) create mode 100644 tests/test_scale.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ccf21b..9f2ec5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.de.md b/README.de.md index 0c14a9e..c9d1bee 100644 --- a/README.de.md +++ b/README.de.md @@ -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.
Dialog „Zusammensetzen“: Korrekturen A bis D, Grenzen zwischen den Aufnahmen, Größe der Frame-Fassung, volle Fassung, Speicherschätzung
Beispieldaten (englische Oberfläche) – Raumnamen und Vorschaubilder sind Platzhalter. +- **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. diff --git a/README.md b/README.md index afbf816..457e98a 100644 --- a/README.md +++ b/README.md @@ -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.
Dialog “Put together”: corrections A to D, borders between the recordings, size of the Frame version, full version, memory estimate
Example data – room names and preview images are placeholders. +- **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).
Combined floor plan of an example home in the classic style, with the measuring tool
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. diff --git a/scripts/combine.sh b/scripts/combine.sh index ec64e2a..46d7de3 100755 --- a/scripts/combine.sh +++ b/scripts/combine.sh @@ -12,6 +12,7 @@ # FULL_NAME="..." name plate of the full version (default: " (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 diff --git a/scripts/floorplan.py b/scripts/floorplan.py index 11d92ec..120499d 100644 --- a/scripts/floorplan.py +++ b/scripts/floorplan.py @@ -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): diff --git a/scripts/to-frame.sh b/scripts/to-frame.sh index d8a4304..d3c9766 100755 --- a/scripts/to-frame.sh +++ b/scripts/to-frame.sh @@ -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: .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 diff --git a/splat_workshop/server.py b/splat_workshop/server.py index ee8d8ea..79fcfdb 100644 --- a/splat_workshop/server.py +++ b/splat_workshop/server.py @@ -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:] diff --git a/tests/test_scale.py b/tests/test_scale.py new file mode 100644 index 0000000..488d321 --- /dev/null +++ b/tests/test_scale.py @@ -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 diff --git a/web/i18n.js b/web/i18n.js index b348e28..a7481eb 100644 --- a/web/i18n.js +++ b/web/i18n.js @@ -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", diff --git a/web/index.html b/web/index.html index 0eae879..7933fc1 100644 --- a/web/index.html +++ b/web/index.html @@ -372,6 +372,20 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background: + +

+

+
+ + + + +
+ + +
+
+

@@ -821,8 +835,9 @@ function sceneCard(s) {
${esc(s.name)} ${new Date(s.mtime * 1000).toLocaleDateString(LANG)} · ${s.gaussians ? tr("sc.splats", {n: num(s.gaussians / 1e6, 2)}) + " · " : ""}${mb(s.size)} ${s.disk ? `${tr("sc.disk", {size: mb(s.disk.total)})}` : ""} + ${scaleLine(s)} ${s.replacing ? `
${tr("sc.replacing")}
` : frameState(s)} -
${uploadBtn(s)}${s.ply ? `` : ""}${s.ply ? `` : ""}
+
${uploadBtn(s)}${s.ply ? `` : ""}${s.ply ? `` : ""}
`; } const ALIGN_LETTER = {frame: "A", images: "B", shift: "C", rigid: "D"}; @@ -854,6 +869,7 @@ function groupBlock(g, bySlug) {
${r && r.preview ? "" : "📁"}
📁 ${esc(g.name)} ${tr("grp.rooms", {n: rooms.length})} · ${tr("sc.disk", {size: mb(disk)})} + ${scaleLine(g)} ${tour}${busy}
@@ -863,6 +879,7 @@ function groupBlock(g, bySlug) { ${r && r.ply ? `` : ""} ${r ? `` : ""} +
@@ -894,6 +911,7 @@ async function loadScenes() { $("#sceneGrid").innerHTML = loose.length ? loose.map(sceneCard).join("") : S.groups.length ? "" : `
${tr("scenes.none")}
`; 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 `${tr("scale.line", {ratio: ratioText(x.scale)})}${x.scale_xr ? " · " + tr("scale.line.xr") : ""}`; +} +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]) => ``).join("") + + ``; + $("#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;