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);
}