quickBar.ts

examples/sogs/quickBar.ts · raw · open the example · all examples · site home

Exports: makeQuickBarformatCount

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// The bar at the top left: open files or a URL, a sample, then the settings
// changed most (method, sort, key bits, SH degree, camera mode, pixel
// ratio) as segmented buttons, and fps with the splat count (its "?" lists
// the camera controls). The frame loop calls refresh(), so it follows
// changes made elsewhere (the panel, the F key, a preset).
import type { CameraControls, CameraControlsMode, ProjectionMethod, SortKeyBits, SplatRenderer } from "@sparkjsdev/radix";
import { SAMPLES, type Sample } from "./catalog.js";
import type { ViewerState } from "./main.js";

export function makeQuickBar(bar: HTMLElement, { state, webgpu, splats, controls, sample, actions }: {
  state: ViewerState;
  webgpu: boolean;
  splats: SplatRenderer;
  controls: CameraControls;
  // The sample shown, if the file is one
  sample(): Sample | undefined;
  actions: { pickFiles(): void; openUrl(url: string): void; openSample(name: string): void; setSh(degree: number): void };
}) {
  const refreshers: (() => void)[] = [];

  const group = (label = "") => {
    const element = Object.assign(document.createElement("div"), { className: "bar-group" });
    if (label) {
      element.append(Object.assign(document.createElement("span"), { className: "bar-label", textContent: label }));
    }
    bar.append(element);
    return element;
  };

  // Buttons for a few choices; the one matching get() is lit
  const segmented = <T>(label: string, choices: [T, string][], get: () => T, set: (value: T) => void, { disabled = false, title = "" } = {}) => {
    const element = group(label);
    element.title = title;
    const buttons = choices.map(([value, textContent]) => {
      const button = Object.assign(document.createElement("button"), { textContent, disabled, onclick: () => set(value) });
      element.append(button);
      return { button, value };
    });
    refreshers.push(() => buttons.forEach(({ button, value }) => button.classList.toggle("active", value === get())));
  };

  const openButton = Object.assign(document.createElement("button"), {
    textContent: "Open...",
    title: "Open a .sog or .zip, or a SOG folder's meta.json with its .webp files (or drop them, or the folder, anywhere)",
    onclick: () => actions.pickFiles(),
  });
  const url = Object.assign(document.createElement("input"), { type: "url", placeholder: "URL + Enter" });
  url.title = "A .sog / .zip, a meta.json (its .webp files beside it) or a LoD lod-meta.json";
  url.onkeydown = (event) => {
    if (event.key === "Enter" && url.value.trim()) {
      actions.openUrl(url.value.trim());
      url.value = "";
      url.blur();
    }
  };
  group().append(openButton, url);

  // Blank while the file shown is not a sample
  const select = Object.assign(document.createElement("select"), { title: "Sample SOG files and LoD scenes" });
  select.append(...Object.entries(SAMPLES).map(([name, each]) => new Option(each.label, name)));
  select.onchange = () => {
    actions.openSample(select.value);
    select.blur();
  };
  group("sample").append(select);
  refreshers.push(() => {
    const shown = sample();
    const index = shown ? Object.values(SAMPLES).indexOf(shown) : -1;
    if (select.selectedIndex !== index) {
      select.selectedIndex = index;
    }
  });

  const methods: ProjectionMethod[] = ["ewa", "3dgut", "htgs", "raygs"];
  segmented("method", methods.map((m) => [m, m]), () => splats.method, (v) => (splats.method = v));
  segmented<"gpu" | "cpu">("sort", [["gpu", "gpu"], ["cpu", "cpu"]], () => splats.sortMode, (v) => (splats.sortMode = v),
    { disabled: !webgpu, title: webgpu ? "gpu: WgpuSplatSorter; cpu: the WebGL2 fallback's worker sort" : "The GPU sort needs WebGPU" });
  segmented<SortKeyBits>("bits", [[16, "16"], [24, "24"], [32, "32"]], () => splats.sortBits, (v) => (splats.sortBits = v));
  segmented("SH", [[0, "0"], [1, "1"], [2, "2"], [3, "3"]], () => state.sh, (v) => actions.setSh(v), { title: "Spherical harmonics degree drawn (up to the file's)" });
  segmented<CameraControlsMode>("camera", [["orbit", "orbit"], ["fly", "fly"]], () => controls.mode, (v) => (controls.mode = v), { title: "F switches" });
  segmented("px", [[false, "1x"], [true, "device"]], () => state.devicePixels, (v) => (state.devicePixels = v), { title: `Pixel ratio: 1 or the device's (${devicePixelRatio})` });

  const stats = Object.assign(document.createElement("span"), { className: "bar-stats" });
  const help = Object.assign(document.createElement("span"), {
    className: "bar-label",
    textContent: "?",
    title: [
      "drag: look (fly) or orbit (orbit); F switches",
      "double press, then drag: slide",
      "right drag / two fingers: pan",
      "wheel / middle drag / pinch: forward and back (fly) or zoom (orbit)",
      "WASD / arrows: move, Q E: down / up, Shift fast, Ctrl slow, Caps Lock on: 10x",
      "1 2 3: gizmo move / rotate / scale, Esc: gizmo off",
    ].join("\n"),
  });
  group().append(stats, help);

  return {
    refresh: () => refreshers.forEach((refresh) => refresh()),
    monitor(fps: number, count: number) {
      stats.textContent = `${Math.round(fps)} fps · ${formatCount(count)} splats`;
    },
  };
}

export function formatCount(v: number) {
  return v >= 1e6 ? `${(v / 1e6).toFixed(2)} M` : v >= 1e4 ? `${(v / 1e3).toFixed(1)} k` : v.toFixed(0);
}