diff --git a/packages/core/.gitignore b/packages/core/.gitignore
new file mode 100644
index 00000000..1521c8b7
--- /dev/null
+++ b/packages/core/.gitignore
@@ -0,0 +1 @@
+dist
diff --git a/packages/core/examples/image2d_from_omezarr3d_f32/index.html b/packages/core/examples/image2d_from_omezarr3d_f32/index.html
new file mode 100644
index 00000000..3b02d2ba
--- /dev/null
+++ b/packages/core/examples/image2d_from_omezarr3d_f32/index.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+ Web Viewer
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/examples/image2d_from_omezarr3d_f32/main.ts b/packages/core/examples/image2d_from_omezarr3d_f32/main.ts
new file mode 100644
index 00000000..0e29b0d1
--- /dev/null
+++ b/packages/core/examples/image2d_from_omezarr3d_f32/main.ts
@@ -0,0 +1,67 @@
+import {
+ LayerManager,
+ ImageLayer,
+ OrthographicCamera,
+ WebGLRenderer,
+ OmeZarrImageSource,
+} from "@";
+import { PanZoomControls } from "@/objects/cameras/controls";
+
+const sliderMin = document.getElementById("slider-min") as HTMLInputElement;
+const labelMin = document.getElementById("label-min") as HTMLLabelElement;
+const sliderMax = document.getElementById("slider-max") as HTMLInputElement;
+const labelMax = document.getElementById("label-max") as HTMLLabelElement;
+
+const url =
+ "https://files.cryoetdataportal.cziscience.com/10444/24apr23a_Position_12/Reconstructions/VoxelSpacing4.990/Tomograms/100/24apr23a_Position_12.zarr";
+const pixelScale = 4.99;
+const layerManager = new LayerManager();
+const renderer = new WebGLRenderer("#canvas");
+const camera = new OrthographicCamera(
+ 0,
+ pixelScale * 1264,
+ 0,
+ pixelScale * 1264
+);
+const controls = new PanZoomControls(camera, camera.position);
+renderer.setControls(controls);
+const region = [{ dimension: "z", index: pixelScale * 120 }];
+
+const source = new OmeZarrImageSource(url);
+const layer = new ImageLayer({ source, region });
+layerManager.add(layer);
+
+const onMinChange = () => {
+ const minValue = sliderMin.valueAsNumber;
+ const maxValue = sliderMax.valueAsNumber;
+ if (minValue >= maxValue) {
+ sliderMin.value = (maxValue - Number(sliderMin.step)).toString();
+ } else {
+ labelMin.innerText = `Min: ${minValue.toString()}`;
+ layer.setChannelProps([{ contrastLimits: [minValue, maxValue] }]);
+ }
+};
+
+const onMaxChange = () => {
+ const maxValue = sliderMax.valueAsNumber;
+ const minValue = sliderMin.valueAsNumber;
+ if (maxValue <= minValue) {
+ sliderMax.value = (minValue + Number(sliderMax.step)).toString();
+ } else {
+ labelMax.innerText = `Max: ${maxValue.toString()}`;
+ layer.setChannelProps([{ contrastLimits: [minValue, maxValue] }]);
+ }
+};
+
+sliderMin.addEventListener("input", onMinChange);
+sliderMax.addEventListener("input", onMaxChange);
+
+onMinChange();
+onMaxChange();
+
+function animate() {
+ renderer.render(layerManager, camera);
+ requestAnimationFrame(animate);
+}
+
+animate();
diff --git a/packages/core/examples/image2d_from_omezarr4d_hcs/index.html b/packages/core/examples/image2d_from_omezarr4d_hcs/index.html
new file mode 100644
index 00000000..9b4c155f
--- /dev/null
+++ b/packages/core/examples/image2d_from_omezarr4d_hcs/index.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+ Web Viewer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/examples/image2d_from_omezarr4d_hcs/main.ts b/packages/core/examples/image2d_from_omezarr4d_hcs/main.ts
new file mode 100644
index 00000000..756672e4
--- /dev/null
+++ b/packages/core/examples/image2d_from_omezarr4d_hcs/main.ts
@@ -0,0 +1,94 @@
+import {
+ LayerManager,
+ ImageLayer,
+ OrthographicCamera,
+ WebGLRenderer,
+ OmeZarrImageSource,
+} from "@";
+import { PanZoomControls } from "@/objects/cameras/controls";
+import {
+ loadOmeZarrPlate,
+ loadOmeZarrWell,
+} from "@/data/ome_zarr_hcs_metadata_loader";
+
+// The following data comes from Zenodo: https://zenodo.org/records/11262587
+// First download and unpack it. Then host the containing directory locally
+// with something like:
+// http-server --cors -p 8080
+const plateUrl =
+ "http://localhost:8080/20200812-CardiomyocyteDifferentiation14-Cycle1_mip.zarr";
+const plate = await loadOmeZarrPlate(plateUrl);
+console.debug("plate", plate);
+if (plate.plate === undefined) {
+ throw new Error(`No plate found: ${plate}`);
+}
+const wellPaths = plate.plate.wells.map((well) => well.path);
+const wellSelector = document.querySelector("#well") as HTMLSelectElement;
+wellPaths.forEach((path) => {
+ const option = document.createElement("option");
+ option.value = path;
+ option.text = path;
+ wellSelector.appendChild(option);
+});
+
+const layerManager = new LayerManager();
+const renderer = new WebGLRenderer("#canvas");
+const camera = new OrthographicCamera(0, 840, 0, 360);
+const controls = new PanZoomControls(camera, camera.position);
+renderer.setControls(controls);
+const region = [
+ { dimension: "c", index: { start: 0, stop: 3 } },
+ { dimension: "z", index: 0 },
+];
+
+const imageSelector = document.querySelector("#image") as HTMLSelectElement;
+
+const onImageChange = async () => {
+ console.debug("onImageChange: ", imageSelector.value);
+ layerManager.layers.length = 0;
+ const imageUrl =
+ plateUrl + "/" + wellSelector.value + "/" + imageSelector.value;
+ const source = new OmeZarrImageSource(imageUrl);
+ const layer = new ImageLayer({
+ source,
+ region,
+ channelProps: [
+ // color and contrast limits manually looked up in the zarr omero metadata
+ { color: [0, 1, 1], contrastLimits: [110, 800] },
+ { color: [1, 0, 1], contrastLimits: [110, 250] },
+ { color: [1, 1, 0], contrastLimits: [110, 800] },
+ ],
+ });
+ layerManager.add(layer);
+};
+
+const onWellChange = async () => {
+ console.debug("onWellChange: ", wellSelector.value);
+ layerManager.layers.length = 0;
+ const path = wellSelector.value;
+ const well = await loadOmeZarrWell(plateUrl, path);
+ console.debug("well", well);
+ if (well.well === undefined) {
+ throw new Error(`No well found: ${well}`);
+ }
+ const imagePaths = well.well.images.map((image) => image.path);
+ imageSelector.innerHTML = "";
+ imagePaths.forEach((path) => {
+ const option = document.createElement("option");
+ option.value = path;
+ option.text = path;
+ imageSelector.appendChild(option);
+ });
+ await onImageChange();
+};
+
+wellSelector.addEventListener("change", onWellChange);
+imageSelector.addEventListener("change", onImageChange);
+await onWellChange();
+
+function animate() {
+ renderer.render(layerManager, camera);
+ requestAnimationFrame(animate);
+}
+
+animate();
diff --git a/packages/core/examples/image2d_from_omezarr5d_u16/index.html b/packages/core/examples/image2d_from_omezarr5d_u16/index.html
new file mode 100644
index 00000000..4353a472
--- /dev/null
+++ b/packages/core/examples/image2d_from_omezarr5d_u16/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Web Viewer
+
+
+
+
+
+
+
diff --git a/packages/core/examples/image2d_from_omezarr5d_u16/main.ts b/packages/core/examples/image2d_from_omezarr5d_u16/main.ts
new file mode 100644
index 00000000..2393c0ab
--- /dev/null
+++ b/packages/core/examples/image2d_from_omezarr5d_u16/main.ts
@@ -0,0 +1,44 @@
+import {
+ LayerManager,
+ ImageLayer,
+ WebGLRenderer,
+ OmeZarrImageSource,
+ OrthographicCamera,
+} from "@";
+import { AxesLayer } from "@/layers/axes_layer";
+import { PanZoomControls } from "@/objects/cameras/controls";
+
+const url =
+ "https://public.czbiohub.org/royerlab/zebrahub/imaging/single-objective/ZSNS001.ome.zarr/";
+const layerManager = new LayerManager();
+const renderer = new WebGLRenderer("#canvas");
+const left = 150;
+const right = 950;
+const top = 100;
+const bottom = 900;
+const camera = new OrthographicCamera(left, right, top, bottom);
+const controls = new PanZoomControls(camera, camera.position);
+renderer.setControls(controls);
+
+// Source is 5D, so provide indices at 3 dimensions to project to 2D.
+// Also specify a subregion in x and y to exercise that part of the API.
+const source = new OmeZarrImageSource(url);
+const region = [
+ { dimension: "t", index: 400 },
+ { dimension: "c", index: 0 },
+ { dimension: "z", index: 300 },
+ { dimension: "y", index: { start: top, stop: bottom } },
+ { dimension: "x", index: { start: left, stop: right } },
+];
+const channelProps = [{ contrastLimits: [0, 255] as [number, number] }];
+const layer = new ImageLayer({ source, region, channelProps });
+const axes = new AxesLayer({ length: 2000, width: 0.01 });
+layerManager.add(layer);
+layerManager.add(axes);
+
+function animate() {
+ renderer.render(layerManager, camera);
+ requestAnimationFrame(animate);
+}
+
+animate();
diff --git a/packages/core/examples/image2d_from_omezarr5d_u8/index.html b/packages/core/examples/image2d_from_omezarr5d_u8/index.html
new file mode 100644
index 00000000..4353a472
--- /dev/null
+++ b/packages/core/examples/image2d_from_omezarr5d_u8/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Web Viewer
+
+
+
+
+
+
+
diff --git a/packages/core/examples/image2d_from_omezarr5d_u8/main.ts b/packages/core/examples/image2d_from_omezarr5d_u8/main.ts
new file mode 100644
index 00000000..b8fe0e7f
--- /dev/null
+++ b/packages/core/examples/image2d_from_omezarr5d_u8/main.ts
@@ -0,0 +1,65 @@
+import { vec3 } from "gl-matrix";
+import {
+ LayerManager,
+ ImageLayer,
+ WebGLRenderer,
+ OmeZarrImageSource,
+ OrthographicCamera,
+ PerspectiveCamera,
+} from "@";
+import { AxesLayer } from "@/layers/axes_layer";
+import { PanZoomControls } from "@/objects/cameras/controls";
+
+const url =
+ "https://public.czbiohub.org/royerlab/ultrack/multi-color/image.zarr/";
+const layerManager = new LayerManager();
+const renderer = new WebGLRenderer("#canvas");
+const orthoCam = new OrthographicCamera(-2000, 2000, -2000, 2000);
+const cameraPos = vec3.fromValues(0, 0, 5000);
+const perspectiveCam = new PerspectiveCamera({ fov: 60, position: cameraPos });
+
+// Source is technically 5D (even though Z is unitary),
+// so provide indices at 3 dimensions to project to 2D.
+const source = new OmeZarrImageSource(url);
+const region = [
+ { dimension: "T", index: 150 },
+ { dimension: "C", index: 0 },
+ { dimension: "Z", index: 0 },
+];
+const channelProps = [
+ {
+ color: [0, 1, 0] as [number, number, number],
+ contrastLimits: [0, 128] as [number, number],
+ },
+];
+const layer = new ImageLayer({ source, region, channelProps });
+const axes = new AxesLayer({ length: 1920, width: 0.01 });
+layerManager.add(layer);
+layerManager.add(axes);
+
+let camera: PerspectiveCamera | OrthographicCamera = perspectiveCam;
+
+const orthoCamControls = new PanZoomControls(orthoCam);
+const perspectiveCamControls = new PanZoomControls(perspectiveCam);
+
+renderer.setControls(perspectiveCamControls);
+
+function animate() {
+ renderer.render(layerManager, camera);
+ requestAnimationFrame(animate);
+}
+
+document.addEventListener("keydown", (event) => {
+ if (event.key === " ") {
+ toggleCamera();
+ }
+});
+
+function toggleCamera() {
+ camera = camera === orthoCam ? perspectiveCam : orthoCam;
+ renderer.setControls(
+ camera === orthoCam ? orthoCamControls : perspectiveCamControls
+ );
+}
+
+animate();
diff --git a/packages/core/examples/image_series_from_omezarr5d_u8/index.html b/packages/core/examples/image_series_from_omezarr5d_u8/index.html
new file mode 100644
index 00000000..4f2bf772
--- /dev/null
+++ b/packages/core/examples/image_series_from_omezarr5d_u8/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+ Web Viewer
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/examples/image_series_from_omezarr5d_u8/main.ts b/packages/core/examples/image_series_from_omezarr5d_u8/main.ts
new file mode 100644
index 00000000..bc745dfb
--- /dev/null
+++ b/packages/core/examples/image_series_from_omezarr5d_u8/main.ts
@@ -0,0 +1,71 @@
+import {
+ LayerManager,
+ LayerState,
+ ImageSeriesLayer,
+ OrthographicCamera,
+ WebGLRenderer,
+ OmeZarrImageSource,
+} from "@";
+
+const url =
+ "https://public.czbiohub.org/royerlab/ultrack/multi-color/image.zarr/";
+const layerManager = new LayerManager();
+const renderer = new WebGLRenderer("#canvas");
+const camera = new OrthographicCamera(0, 1920, 0, 1440);
+
+// Source is 5D, so provide an interval in T a scalar index in Z
+// (first of only depth) to get a 2D image series.
+const source = new OmeZarrImageSource(url);
+const timeInterval = { start: 100, stop: 120 };
+const region = [
+ { dimension: "T", index: timeInterval },
+ { dimension: "Z", index: 0 },
+];
+// Raise the contrast limits for the blue channel because there is
+// a lot of low signal that washes everything else out.
+const channelProps = [
+ {
+ visible: false,
+ color: [1, 0, 0] as [number, number, number],
+ contrastLimits: [0, 255] as [number, number],
+ },
+ {
+ visible: true,
+ color: [0, 1, 0] as [number, number, number],
+ contrastLimits: [0, 255] as [number, number],
+ },
+ {
+ visible: true,
+ color: [0, 0, 1] as [number, number, number],
+ contrastLimits: [128, 255] as [number, number],
+ },
+];
+const layer = new ImageSeriesLayer({
+ source,
+ region,
+ timeDimension: "T",
+ channelProps,
+});
+layerManager.add(layer);
+
+const slider = document.querySelector("#slider");
+if (slider === null) throw new Error("Time slider not found.");
+slider.min = timeInterval.start.toString();
+slider.max = (timeInterval.stop - 1).toString();
+
+layer.addStateChangeCallback((newState: LayerState) => {
+ if (newState === "ready") {
+ slider.addEventListener("input", (event) => {
+ const value = (event.target as HTMLInputElement).valueAsNumber;
+ layer.setTimeIndex(value);
+ });
+ layer.setTimeIndex(slider.valueAsNumber);
+ }
+});
+
+function animate() {
+ renderer.render(layerManager, camera);
+ requestAnimationFrame(animate);
+}
+
+animate();
diff --git a/packages/core/examples/index.html b/packages/core/examples/index.html
new file mode 100644
index 00000000..898db0ca
--- /dev/null
+++ b/packages/core/examples/index.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+ Web Viewer
+
+
+
+ Examples
+
+
+
diff --git a/packages/core/examples/projected_lines/index.html b/packages/core/examples/projected_lines/index.html
new file mode 100644
index 00000000..4353a472
--- /dev/null
+++ b/packages/core/examples/projected_lines/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Web Viewer
+
+
+
+
+
+
+
diff --git a/packages/core/examples/projected_lines/main.ts b/packages/core/examples/projected_lines/main.ts
new file mode 100644
index 00000000..de398a1c
--- /dev/null
+++ b/packages/core/examples/projected_lines/main.ts
@@ -0,0 +1,64 @@
+import {
+ LayerManager,
+ PerspectiveCamera,
+ ProjectedLineLayer,
+ WebGLRenderer,
+} from "@";
+
+const layersManager = new LayerManager();
+
+const helixA = generateHelix({
+ radius: 1.0,
+ turns: 3.0,
+ pointsPerTurn: 100,
+ pitch: 1.5,
+ phase: 0.0,
+});
+const helixB = generateHelix({
+ radius: 1.0,
+ turns: 3.0,
+ pointsPerTurn: 100,
+ pitch: 1.5,
+ phase: 90.0,
+});
+const layer = new ProjectedLineLayer([
+ { path: helixA, color: [1.0, 0.7, 0.0], width: 0.01 },
+ { path: helixB, color: [0.0, 0.7, 0.0], width: 0.02 },
+]);
+
+layersManager.add(layer);
+
+const renderer = new WebGLRenderer("#canvas");
+const camera = new PerspectiveCamera({
+ fov: 60,
+ aspectRatio: renderer.width / renderer.height,
+});
+
+animate();
+
+function animate() {
+ renderer.render(layersManager, camera);
+ requestAnimationFrame(animate);
+}
+
+function generateHelix(params: {
+ radius: number;
+ turns: number;
+ pointsPerTurn: number;
+ pitch: number;
+ phase: number;
+}) {
+ const { radius, turns, pointsPerTurn, pitch, phase } = params;
+ const phaseRad = (phase * Math.PI) / 180;
+ const path: [number, number, number][] = [];
+ const totalPoints = turns * pointsPerTurn;
+
+ for (let i = 0; i < totalPoints; i++) {
+ const angle = (i / pointsPerTurn) * 2 * Math.PI;
+ const z = radius * Math.cos(angle + phaseRad) - 5.0;
+ const y = (i / pointsPerTurn) * pitch - (pitch * turns) / 2;
+ const x = radius * Math.sin(angle + phaseRad);
+ path.push([x, y, z]);
+ }
+ return path;
+}
diff --git a/packages/core/examples/single_mesh_layer/index.html b/packages/core/examples/single_mesh_layer/index.html
new file mode 100644
index 00000000..4353a472
--- /dev/null
+++ b/packages/core/examples/single_mesh_layer/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Web Viewer
+
+
+
+
+
+
+
diff --git a/packages/core/examples/single_mesh_layer/main.ts b/packages/core/examples/single_mesh_layer/main.ts
new file mode 100644
index 00000000..536435f2
--- /dev/null
+++ b/packages/core/examples/single_mesh_layer/main.ts
@@ -0,0 +1,20 @@
+import {
+ LayerManager,
+ OrthographicCamera,
+ SingleMeshLayer,
+ WebGLRenderer,
+} from "@";
+
+const singleMeshLayer = new SingleMeshLayer();
+const layersManager = new LayerManager();
+layersManager.add(singleMeshLayer);
+
+const renderer = new WebGLRenderer("#canvas");
+const camera = new OrthographicCamera(0, 870, 0, 870);
+
+function animate() {
+ renderer.render(layersManager, camera);
+ requestAnimationFrame(animate);
+}
+
+animate();
diff --git a/packages/core/examples/tracks/index.html b/packages/core/examples/tracks/index.html
new file mode 100644
index 00000000..4f2bf772
--- /dev/null
+++ b/packages/core/examples/tracks/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+ Web Viewer
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/examples/tracks/main.ts b/packages/core/examples/tracks/main.ts
new file mode 100644
index 00000000..725052e5
--- /dev/null
+++ b/packages/core/examples/tracks/main.ts
@@ -0,0 +1,133 @@
+import { vec3 } from "gl-matrix";
+import {
+ ImageSeriesLayer,
+ LayerManager,
+ LayerState,
+ OrthographicCamera,
+ OmeZarrImageSource,
+ TracksLayer,
+ WebGLRenderer,
+} from "@";
+import { PanZoomControls } from "@/objects/cameras/controls";
+
+// payload roughly equivalent to task 0 from
+// https://public.czbiohub.org/royerlab/ultrack/multi-color/mock_data.json
+const trackAPath: vec3[] = [
+ [1822.0, 1350.0, 0.0],
+ [1825.0, 1350.0, 0.0],
+ [1831.0, 1351.0, 0.0],
+ [1826.0, 1350.0, 0.0],
+ [1827.0, 1350.0, 0.0],
+ [1827.0, 1351.0, 0.0],
+];
+const trackATime = [28, 29, 30, 31, 32, 33];
+const trackBPath: vec3[] = [
+ [1818.0, 1347.0, 0.0],
+ [1820.0, 1343.0, 0.0],
+ [1820.0, 1341.0, 0.0],
+ [1824.0, 1345.0, 0.0],
+ [1824.0, 1350.0, 0.0],
+];
+const trackBTime = [34, 35, 36, 37, 38];
+const trackCPath: vec3[] = [
+ [1841.0, 1353.0, 0.0],
+ [1842.0, 1356.0, 0.0],
+ [1842.0, 1356.0, 0.0],
+ [1840.0, 1367.0, 0.0],
+ [1844.0, 1378.0, 0.0],
+];
+const trackCTime = [34, 35, 36, 37, 38];
+const interpolation = { pointsPerSegment: 10, tangentFactor: 0.3 };
+
+const lineLayer = new TracksLayer([
+ {
+ path: trackAPath,
+ time: trackATime,
+ color: [1.0, 0.0, 0.0],
+ width: 0.02,
+ interpolation,
+ },
+ {
+ path: trackBPath,
+ time: trackBTime,
+ color: [0.0, 1.0, 0.0],
+ width: 0.02,
+ interpolation,
+ },
+ {
+ path: trackCPath,
+ time: trackCTime,
+ color: [0.0, 1.0, 1.0],
+ width: 0.02,
+ interpolation,
+ },
+]);
+
+const url =
+ "https://public.czbiohub.org/royerlab/ultrack/multi-color/image.zarr/";
+const source = new OmeZarrImageSource(url);
+const timeInterval = { start: 28, stop: 39 };
+const region = [
+ { dimension: "T", index: timeInterval },
+ { dimension: "Z", index: 0 },
+];
+// Raise the contrast limits for the blue channel because there is
+// a lot of low signal that washes everything else out.
+const channelProps = [
+ {
+ color: [1, 0, 0] as [number, number, number],
+ contrastLimits: [0, 255] as [number, number],
+ },
+ {
+ color: [0, 1, 0] as [number, number, number],
+ contrastLimits: [0, 255] as [number, number],
+ },
+ {
+ color: [0, 0, 1] as [number, number, number],
+ contrastLimits: [128, 255] as [number, number],
+ },
+];
+const imageSeriesLayer = new ImageSeriesLayer({
+ source,
+ region,
+ timeDimension: "T",
+ channelProps,
+});
+
+const renderer = new WebGLRenderer("#canvas");
+
+const layerManager = new LayerManager();
+layerManager.add(imageSeriesLayer);
+layerManager.add(lineLayer);
+
+const { xMin: left, xMax: right, yMin: top, yMax: bottom } = lineLayer.extent;
+const camera = new OrthographicCamera(left, right, top, bottom);
+camera.zoom = 0.5;
+camera.transform.translate([0, 0, 1]);
+
+const controls = new PanZoomControls(camera, camera.position);
+renderer.setControls(controls);
+
+const slider = document.querySelector("#slider");
+if (slider === null) throw new Error("Time slider not found.");
+slider.min = timeInterval.start.toString();
+slider.max = (timeInterval.stop - 1).toString();
+
+imageSeriesLayer.addStateChangeCallback((newState: LayerState) => {
+ if (newState === "ready") {
+ slider.addEventListener("input", (event) => {
+ const value = (event.target as HTMLInputElement).valueAsNumber;
+ imageSeriesLayer.setTimeIndex(value);
+ lineLayer.setTimeIndex(value);
+ });
+ imageSeriesLayer.setTimeIndex(slider.valueAsNumber);
+ lineLayer.setTimeIndex(slider.valueAsNumber);
+ }
+});
+
+animate();
+
+function animate() {
+ renderer.render(layerManager, camera);
+ requestAnimationFrame(animate);
+}
diff --git a/packages/core/package.json b/packages/core/package.json
new file mode 100644
index 00000000..aed03fa6
--- /dev/null
+++ b/packages/core/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@idetik/core",
+ "private": true,
+ "version": "0.0.1",
+ "main": "dist/index.cjs.js",
+ "types": "dist/index.cjs.d.ts",
+ "module": "dist/index.esm.js",
+ "type": "module",
+ "scripts": {
+ "build": "rollup -c",
+ "build-watch": "rollup -c -w",
+ "lint": "concurrently \"../../node_modules/.bin/eslint src/**/*.{ts,tsx} --quiet --fix\" \"../../node_modules/.bin/stylelint --fix '**/*.{js,ts,tsx,css}'\" \"npm run type-check\"",
+ "type-check": "tsc --noEmit",
+ "test": "jest --passWithNoTests",
+ "chromatic": "npx chromatic --exit-zero-on-changes --project-token=cad4aacbfeba",
+ "namespace-check": "tsc --p tsconfig-namespace-check.json"
+ },
+ "dependencies": {
+ "gl-matrix": "^3.4.3",
+ "zarrita": "^0.4.0-next.16",
+ "zod": "^3.24.1"
+ },
+ "description": "A layer-based visualization abstraction",
+ "files": [
+ "dist"
+ ],
+ "directories": {
+ "test": "test"
+ },
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "rollup-plugin-glsl": "^1.3.0",
+ "vite-plugin-dts": "^4.5.0"
+ }
+}
diff --git a/packages/core/public/texture_test.png b/packages/core/public/texture_test.png
new file mode 100644
index 00000000..f3f8db4f
Binary files /dev/null and b/packages/core/public/texture_test.png differ
diff --git a/packages/core/rollup.config.mjs b/packages/core/rollup.config.mjs
new file mode 100644
index 00000000..27207891
--- /dev/null
+++ b/packages/core/rollup.config.mjs
@@ -0,0 +1,71 @@
+import url from "@rollup/plugin-url";
+import svgr from "@svgr/rollup";
+import del from "rollup-plugin-delete";
+import ts from "rollup-plugin-ts";
+import resolve from "@rollup/plugin-node-resolve";
+import commonjs from "@rollup/plugin-commonjs";
+import pkg from "./package.json" assert { type: "json" };
+import glsl from "rollup-plugin-glsl";
+
+const config = [
+ {
+ // External dependencies that should not be bundled into the output to reduce bundle size.
+ external: [...Object.keys(pkg.peerDependencies || {}), "react/jsx-runtime"],
+
+ // Entry point for the library
+ input: "src/index.ts",
+
+ // Output configuration for the CommonJS (CJS) and ESModule (ESM) formats.
+ output: [
+ {
+ // Ensures "use client" is added at the top of each output file.
+ banner: '"use client";',
+ file: pkg.main,
+ format: "cjs",
+ interop: "auto",
+ inlineDynamicImports: true,
+ },
+ {
+ // Ensures "use client" is added at the top of each output file.
+ banner: '"use client";',
+ file: pkg.module,
+ format: "esm",
+ interop: "auto",
+ inlineDynamicImports: true,
+ },
+ ],
+ plugins: [
+ // Handles resolving external dependencies from `node_modules`.
+ resolve(),
+
+ glsl({
+ include: ["**/*.glsl"],
+ }),
+
+ // Convert CommonJS modules (especially from `node_modules`) to ES6.
+ commonjs(),
+
+ // Clean up the `dist` folder and any other specified paths before building.
+ // del({
+ // targets: ["dist/*", "playground/src/data-viz"],
+ // }),
+
+ // Allow imports for assets like images, fonts, etc., and handle them properly.
+ // url({ exclude: ["**/*.glsl"] }),
+
+ // Transform imported SVGs into React components.
+ svgr(),
+
+ // Handle TypeScript compilation with custom configurations.
+ ts({
+ tsconfig: (resolvedConfig) => ({
+ ...resolvedConfig,
+ // Exclude SCSS and CSS files from TypeScript's scope.
+ exclude: ["**/*.scss", "**/*.css"],
+ }),
+ }),
+ ],
+ },
+];
+
+export default config;
diff --git a/packages/core/src/core/constants.ts b/packages/core/src/core/constants.ts
new file mode 100644
index 00000000..7ca37ffb
--- /dev/null
+++ b/packages/core/src/core/constants.ts
@@ -0,0 +1,2 @@
+// Maximum number of channels supported in multichannel images
+export const MAX_CHANNELS = 32;
diff --git a/packages/core/src/core/geometry.ts b/packages/core/src/core/geometry.ts
new file mode 100644
index 00000000..faf44356
--- /dev/null
+++ b/packages/core/src/core/geometry.ts
@@ -0,0 +1,61 @@
+import { Node } from "./node";
+
+type GeometryAttributeType =
+ | "position"
+ | "normal"
+ | "uv"
+ | "next_position"
+ | "previous_position"
+ | "direction"
+ | "path_proportion";
+
+type GeometryAttribute = {
+ type: GeometryAttributeType;
+ itemSize: number;
+ offset: number;
+};
+
+export class Geometry extends Node {
+ protected vertexData_: Float32Array;
+ protected indexData_: Uint32Array;
+ private attributes_: GeometryAttribute[];
+
+ constructor(vertexData: number[] = [], indexData: number[] = []) {
+ super();
+ this.vertexData_ = new Float32Array(vertexData);
+ this.indexData_ = new Uint32Array(indexData);
+ this.attributes_ = [];
+ }
+
+ public addAttribute(attr: GeometryAttribute) {
+ this.attributes_.push(attr);
+ }
+
+ public get itemSize() {
+ return this.vertexData_.length / this.stride;
+ }
+
+ public get stride() {
+ return (
+ this.attributes_.reduce((acc, curr) => {
+ return (acc += curr.itemSize);
+ }, 0) * Float32Array.BYTES_PER_ELEMENT
+ );
+ }
+
+ public get vertexData() {
+ return this.vertexData_;
+ }
+
+ public get indexData() {
+ return this.indexData_;
+ }
+
+ public get attributes() {
+ return this.attributes_;
+ }
+
+ public get type() {
+ return "Geometry";
+ }
+}
diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts
new file mode 100644
index 00000000..8c143da6
--- /dev/null
+++ b/packages/core/src/core/index.ts
@@ -0,0 +1,9 @@
+export * from "./constants";
+export * from "./geometry";
+export * from "./layer";
+export * from "./layer_manager";
+export * from "./node";
+export * from "./renderable_object";
+export * from "./renderer";
+export * from "./transforms";
+export * from "./utils";
diff --git a/packages/core/src/core/layer.ts b/packages/core/src/core/layer.ts
new file mode 100644
index 00000000..06811617
--- /dev/null
+++ b/packages/core/src/core/layer.ts
@@ -0,0 +1,51 @@
+import { RenderableObject } from "./renderable_object";
+
+export type LayerState = "initialized" | "loading" | "ready";
+
+type StateChangeCallback = (
+ newState: LayerState,
+ prevState?: LayerState
+) => void;
+
+export abstract class Layer {
+ private objects_: RenderableObject[] = [];
+ private state_: LayerState = "initialized";
+ private callbacks_: StateChangeCallback[] = [];
+
+ public abstract update(): void;
+
+ public get objects() {
+ return this.objects_;
+ }
+
+ public get state() {
+ return this.state_;
+ }
+
+ public addStateChangeCallback(callback: StateChangeCallback) {
+ this.callbacks_.push(callback);
+ }
+
+ public removeStateChangeCallback(callback: StateChangeCallback) {
+ const index = this.callbacks_.indexOf(callback);
+ if (index === undefined) {
+ throw new Error(`Callback to remove could not be found: ${callback}`);
+ }
+ this.callbacks_.splice(index, 1);
+ }
+
+ protected setState(newState: LayerState) {
+ const prevState = this.state_;
+ this.state_ = newState;
+ console.debug(`${this.constructor.name} state change: ${newState}`);
+ this.callbacks_.forEach((callback) => callback(newState, prevState));
+ }
+
+ protected addObject(object: RenderableObject) {
+ this.objects_.push(object);
+ }
+
+ protected clearObjects() {
+ this.objects_ = [];
+ }
+}
diff --git a/packages/core/src/core/layer_manager.ts b/packages/core/src/core/layer_manager.ts
new file mode 100644
index 00000000..4e4fd120
--- /dev/null
+++ b/packages/core/src/core/layer_manager.ts
@@ -0,0 +1,13 @@
+import { Layer } from "src/core/layer";
+
+export class LayerManager {
+ private layers_: Layer[] = [];
+
+ public add(layer: Layer) {
+ this.layers_.push(layer);
+ }
+
+ public get layers() {
+ return this.layers_;
+ }
+}
diff --git a/packages/core/src/core/node.ts b/packages/core/src/core/node.ts
new file mode 100644
index 00000000..d8209332
--- /dev/null
+++ b/packages/core/src/core/node.ts
@@ -0,0 +1,7 @@
+import { generateUUID } from "./utils";
+
+export abstract class Node {
+ public readonly uuid = generateUUID();
+
+ public abstract get type(): string;
+}
diff --git a/packages/core/src/core/renderable_object.ts b/packages/core/src/core/renderable_object.ts
new file mode 100644
index 00000000..596e0197
--- /dev/null
+++ b/packages/core/src/core/renderable_object.ts
@@ -0,0 +1,55 @@
+import { Node } from "./node";
+import { Geometry } from "./geometry";
+import { Texture } from "../objects/textures/texture";
+import { AffineTransform } from "./transforms";
+
+import { Shader } from "../renderers/shaders";
+
+export abstract class RenderableObject extends Node {
+ private geometry_ = new Geometry();
+
+ private textures_: Texture[] = [];
+
+ private transform_ = new AffineTransform();
+
+ private programName_: Shader | null = null;
+
+ public addTexture(texture: Texture) {
+ this.textures_.push(texture);
+ }
+
+ public get geometry() {
+ return this.geometry_;
+ }
+
+ public get textures() {
+ return this.textures_;
+ }
+
+ public get transform() {
+ return this.transform_;
+ }
+
+ public set geometry(geometry: Geometry) {
+ this.geometry_ = geometry;
+ }
+
+ public get programName(): Shader {
+ if (this.programName_ === null) {
+ throw new Error("Program name not set");
+ }
+ return this.programName_;
+ }
+
+ protected set programName(programName: Shader) {
+ this.programName_ = programName;
+ }
+
+ /**
+ * Get uniforms for shader program. Override in derived classes that need custom uniforms.
+ * @returns Object containing uniform name-value pairs
+ */
+ public getUniforms(): Record {
+ return {}; // Default implementation returns no uniforms
+ }
+}
diff --git a/packages/core/src/core/renderer.ts b/packages/core/src/core/renderer.ts
new file mode 100644
index 00000000..0c5e9cbb
--- /dev/null
+++ b/packages/core/src/core/renderer.ts
@@ -0,0 +1,145 @@
+import { vec2, vec3 } from "gl-matrix";
+import { LayerManager } from "./layer_manager";
+import { Camera } from "../objects/cameras/camera";
+import { RenderableObject } from "./renderable_object";
+import { PerspectiveCamera } from "../objects/cameras/perspective_camera";
+import { OrthographicCamera } from "../objects/cameras/orthographic_camera";
+import { CameraControls, NullControls } from "../objects/cameras/controls";
+
+type Color = [number, number, number, number];
+
+export abstract class Renderer {
+ private readonly canvas_: HTMLCanvasElement | null;
+
+ private width_ = 0;
+
+ private height_ = 0;
+
+ private backgroundColor_: Color = [0, 0, 0, 0];
+
+ private activeCamera_: Camera | null = null;
+
+ private controls_: CameraControls = new NullControls();
+
+ private controlCallbacks_: [string, (event: Event) => void][] = [];
+
+ protected abstract resize(width: number, height: number): void;
+ protected abstract renderObject(object: RenderableObject): void;
+ protected abstract clear(): void;
+
+ constructor(selector: string) {
+ this.canvas_ = document.querySelector(selector);
+ if (!this.canvas_) {
+ throw new Error(`Canvas element not found for selector "${selector}"`);
+ }
+ this.updateRendererSize();
+ window.addEventListener("resize", () => {
+ this.updateRendererSize();
+ this.resize(this.width_, this.height_);
+ });
+ }
+
+ public render(layerManager: LayerManager, camera: Camera) {
+ this.clear();
+ if (this.activeCamera_ !== camera) {
+ this.activeCamera_ = camera;
+ this.updateActiveCamera();
+ }
+ layerManager.layers.forEach((layer) => {
+ layer.update();
+ if (layer.state === "ready") {
+ layer.objects.forEach((obj) => {
+ this.renderObject(obj);
+ });
+ }
+ });
+ }
+
+ public setControls(controls: CameraControls) {
+ this.unbindControls();
+ this.controls_ = controls;
+ this.bindControls();
+ }
+
+ private unbindControls() {
+ this.controlCallbacks_.forEach(([event, listener]) => {
+ this.canvas.removeEventListener(event, listener);
+ });
+ this.controlCallbacks_ = [];
+ }
+
+ private bindControls() {
+ const clientToClip = this.clientToClip.bind(this);
+ this.controlCallbacks_ = this.controls_.callbacks(
+ this.canvas,
+ clientToClip
+ );
+ this.controlCallbacks_.forEach(([event, listener]) => {
+ this.canvas.addEventListener(event, listener);
+ });
+ }
+
+ private updateRendererSize() {
+ this.width_ = this.canvas.clientWidth * window.devicePixelRatio;
+ this.height_ = this.canvas.clientHeight * window.devicePixelRatio;
+
+ if (this.canvas.width !== this.width_) this.canvas.width = this.width_;
+ if (this.canvas.height !== this.height_) this.canvas.height = this.height_;
+
+ this.updateActiveCamera();
+ }
+
+ private updateActiveCamera() {
+ const aspectRatio = this.width_ / this.height_;
+ if (this.activeCamera_) {
+ if (this.activeCamera_ instanceof PerspectiveCamera) {
+ this.activeCamera_.setAspectRatio(aspectRatio);
+ }
+ if (this.activeCamera_ instanceof OrthographicCamera) {
+ this.activeCamera_.setViewportAspectRatio(aspectRatio);
+ }
+ this.activeCamera_.update();
+ }
+ }
+
+ protected get canvas() {
+ return this.canvas_!;
+ }
+
+ public get width() {
+ return this.width_;
+ }
+
+ public get height() {
+ return this.height_;
+ }
+
+ public get backgroundColor() {
+ return this.backgroundColor_;
+ }
+
+ public set backgroundColor(color: Color) {
+ this.backgroundColor_ = color;
+ }
+
+ protected get activeCamera() {
+ if (this.activeCamera_ === null) {
+ throw new Error(
+ "Attempted to access the active camera before it was set."
+ );
+ }
+ return this.activeCamera_;
+ }
+
+ public clientToClip(position: vec2, depth: number = 0): vec3 {
+ const x = position[0];
+ const y = position[1];
+
+ const rect = this.canvas.getBoundingClientRect();
+ return vec3.fromValues(
+ (2 * (x - rect.x)) / this.canvas.clientWidth - 1,
+ (2 * (y - rect.y)) / this.canvas.clientHeight - 1,
+ depth
+ );
+ }
+}
diff --git a/packages/core/src/core/transforms.ts b/packages/core/src/core/transforms.ts
new file mode 100644
index 00000000..faab164e
--- /dev/null
+++ b/packages/core/src/core/transforms.ts
@@ -0,0 +1,60 @@
+import { mat4, vec3, quat } from "gl-matrix";
+
+export class AffineTransform {
+ private dirty_ = true;
+
+ private matrix_ = mat4.create();
+
+ private rotation_ = quat.create();
+
+ private translation_ = vec3.create();
+
+ private scale_ = vec3.fromValues(1, 1, 1);
+
+ public rotate(q: quat) {
+ quat.multiply(this.rotation_, this.rotation_, q);
+ vec3.transformQuat(this.translation_, this.translation_, q);
+ this.dirty_ = true;
+ }
+
+ public translate(vec: vec3) {
+ vec3.add(this.translation_, this.translation_, vec);
+ this.dirty_ = true;
+ }
+
+ public setTranslation(vec: vec3) {
+ vec3.copy(this.translation_, vec);
+ this.dirty_ = true;
+ }
+
+ public get translation() {
+ return vec3.clone(this.translation_);
+ }
+
+ public scale(vec: vec3) {
+ vec3.multiply(this.scale_, this.scale_, vec);
+ vec3.multiply(this.translation_, this.translation_, vec);
+ this.dirty_ = true;
+ }
+
+ public get matrix() {
+ if (this.dirty_) {
+ this.computeMatrix();
+ this.dirty_ = false;
+ }
+ return this.matrix_;
+ }
+
+ public get inverse() {
+ return mat4.invert(mat4.create(), this.matrix);
+ }
+
+ private computeMatrix() {
+ mat4.fromRotationTranslationScale(
+ this.matrix_,
+ this.rotation_,
+ this.translation_,
+ this.scale_
+ );
+ }
+}
diff --git a/packages/core/src/core/utils.ts b/packages/core/src/core/utils.ts
new file mode 100644
index 00000000..81a317aa
--- /dev/null
+++ b/packages/core/src/core/utils.ts
@@ -0,0 +1,43 @@
+// prettier-ignore
+const lut = [
+ '00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '0a', '0b',
+ '0c', '0d', '0e', '0f', '10', '11', '12', '13', '14', '15', '16', '17',
+ '18', '19', '1a', '1b', '1c', '1d', '1e', '1f', '20', '21', '22', '23',
+ '24', '25', '26', '27', '28', '29', '2a', '2b', '2c', '2d', '2e', '2f',
+ '30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '3a', '3b',
+ '3c', '3d', '3e', '3f', '40', '41', '42', '43', '44', '45', '46', '47',
+ '48', '49', '4a', '4b', '4c', '4d', '4e', '4f', '50', '51', '52', '53',
+ '54', '55', '56', '57', '58', '59', '5a', '5b', '5c', '5d', '5e', '5f',
+ '60', '61', '62', '63', '64', '65', '66', '67', '68', '69', '6a', '6b',
+ '6c', '6d', '6e', '6f', '70', '71', '72', '73', '74', '75', '76', '77',
+ '78', '79', '7a', '7b', '7c', '7d', '7e', '7f', '80', '81', '82', '83',
+ '84', '85', '86', '87', '88', '89', '8a', '8b', '8c', '8d', '8e', '8f',
+ '90', '91', '92', '93', '94', '95', '96', '97', '98', '99', '9a', '9b',
+ '9c', '9d', '9e', '9f', 'a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7',
+ 'a8', 'a9', 'aa', 'ab', 'ac', 'ad', 'ae', 'af', 'b0', 'b1', 'b2', 'b3',
+ 'b4', 'b5', 'b6', 'b7', 'b8', 'b9', 'ba', 'bb', 'bc', 'bd', 'be', 'bf',
+ 'c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8', 'c9', 'ca', 'cb',
+ 'cc', 'cd', 'ce', 'cf', 'd0', 'd1', 'd2', 'd3', 'd4', 'd5', 'd6', 'd7',
+ 'd8', 'd9', 'da', 'db', 'dc', 'dd', 'de', 'df', 'e0', 'e1', 'e2', 'e3',
+ 'e4', 'e5', 'e6', 'e7', 'e8', 'e9', 'ea', 'eb', 'ec', 'ed', 'ee', 'ef',
+ 'f0', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'fa', 'fb',
+ 'fc', 'fd', 'fe', 'ff'
+];
+
+// prettier-ignore
+export function generateUUID() {
+ const d0 = Math.random() * 0xffffffff | 0;
+ const d1 = Math.random() * 0xffffffff | 0;
+ const d2 = Math.random() * 0xffffffff | 0;
+ const d3 = Math.random() * 0xffffffff | 0;
+ const uuid =
+ lut[d0 & 0xff] + lut[d0 >> 8 & 0xff] +
+ lut[d0 >> 16 & 0xff] + lut[d0 >> 24 & 0xff] + '-' +
+ lut[d1 & 0xff] + lut[d1 >> 8 & 0xff] + '-' +
+ lut[d1 >> 16 & 0x0f | 0x40] + lut[d1 >> 24 & 0xff] + '-' +
+ lut[d2 & 0x3f | 0x80] + lut[d2 >> 8 & 0xff] + '-' +
+ lut[d2 >> 16 & 0xff] + lut[d2 >> 24 & 0xff] +
+ lut[d3 & 0xff] + lut[d3 >> 8 & 0xff] + lut[d3 >> 16 & 0xff] + lut[d3 >> 24 & 0xff];
+ // .toLowerCase() flattens concatenated strings to save heap memory space.
+ return uuid.toLowerCase();
+}
diff --git a/packages/core/src/data/image_chunk.ts b/packages/core/src/data/image_chunk.ts
new file mode 100644
index 00000000..76c53b06
--- /dev/null
+++ b/packages/core/src/data/image_chunk.ts
@@ -0,0 +1,50 @@
+import { Region } from "../data/region";
+import { TextureUnpackRowAlignment } from "../objects/textures/texture";
+import { PromiseScheduler } from "./promise_scheduler";
+
+const imageChunkDataTypes = [Uint8Array, Uint16Array, Float32Array] as const;
+type ImageChunkData = InstanceType<(typeof imageChunkDataTypes)[number]>;
+export function isImageChunkData(value: unknown): value is ImageChunkData {
+ if (
+ imageChunkDataTypes.some(
+ (ImageChunkData) => value instanceof ImageChunkData
+ )
+ ) {
+ return true;
+ }
+ const supportedDataTypeNames = imageChunkDataTypes.map((dtype) => dtype.name);
+ console.debug(
+ `Unsupported image chunk data type: ${value}. Supported data types: ${supportedDataTypeNames}`
+ );
+ return false;
+}
+
+// One 2D chunk of n-dimensional image data.
+// TODO: include the region of this chunk.
+// https://github.com/chanzuckerberg/imaging-active-learning/issues/34
+export type ImageChunk = {
+ data: ImageChunkData;
+ shape: {
+ x: number;
+ y: number;
+ c: number;
+ };
+ rowStride: number;
+ rowAlignmentBytes: TextureUnpackRowAlignment;
+ scale: {
+ x: number;
+ y: number;
+ };
+ offset: {
+ x: number;
+ y: number;
+ };
+};
+
+export type ImageChunkSource = {
+ open(): Promise;
+};
+
+export type ImageChunkLoader = {
+ loadChunk(input: Region, scheduler?: PromiseScheduler): Promise;
+};
diff --git a/packages/core/src/data/index.ts b/packages/core/src/data/index.ts
new file mode 100644
index 00000000..68cd2e33
--- /dev/null
+++ b/packages/core/src/data/index.ts
@@ -0,0 +1,6 @@
+export * from "./image_chunk";
+export * from "./ome_zarr_hcs_metadata_loader";
+export * from "./ome_zarr_image_loader";
+export * from "./ome_zarr_image_source";
+export * from "./promise_scheduler";
+export * from "./region";
diff --git a/packages/core/src/data/ome_ngff/0.4/README.md b/packages/core/src/data/ome_ngff/0.4/README.md
new file mode 100644
index 00000000..75062d58
--- /dev/null
+++ b/packages/core/src/data/ome_ngff/0.4/README.md
@@ -0,0 +1,11 @@
+# OMEZarr 0.4 type definitions
+
+These files were generated by the [zod](https://zod.dev/) utility.
+
+Regenerating these files looks something like this:
+
+```
+git clone git@github.com:ome/ngff.git /tmp/ngff
+# ZSH only:
+for i in $(ls /tmp/ngff/0.4/schemas/*.schema | grep -v strict | xargs basename | cut -d '.' -f 1); do json-refs resolve /tmp/ngff/0.4/schemas/$i.schema | json-schema-to-zod -o $i.ts --name ${(C)i} --withJsdocs --type ${(C)i}; done
+```
diff --git a/packages/core/src/data/ome_ngff/0.4/bf2raw.ts b/packages/core/src/data/ome_ngff/0.4/bf2raw.ts
new file mode 100644
index 00000000..62beda24
--- /dev/null
+++ b/packages/core/src/data/ome_ngff/0.4/bf2raw.ts
@@ -0,0 +1,13 @@
+import { z } from "zod";
+
+/**JSON from OME-NGFF .zattrs*/
+export const Bf2raw = z
+ .object({
+ /**The top-level identifier metadata added by bioformats2raw*/
+ "bioformats2raw.layout": z
+ .literal(3)
+ .describe("The top-level identifier metadata added by bioformats2raw")
+ .optional(),
+ })
+ .describe("JSON from OME-NGFF .zattrs");
+export type Bf2raw = z.infer;
diff --git a/packages/core/src/data/ome_ngff/0.4/image.ts b/packages/core/src/data/ome_ngff/0.4/image.ts
new file mode 100644
index 00000000..cb4d2178
--- /dev/null
+++ b/packages/core/src/data/ome_ngff/0.4/image.ts
@@ -0,0 +1,149 @@
+import { z } from "zod";
+
+/**JSON from OME-NGFF .zattrs*/
+export const Image = z
+ .object({
+ /**The multiscale datasets for this image*/
+ multiscales: z
+ .array(
+ z.object({
+ name: z.string().optional(),
+ datasets: z
+ .array(
+ z.object({
+ path: z.string(),
+ coordinateTransformations: z
+ .array(
+ z.any().superRefine((x, ctx) => {
+ const schemas = [
+ z.object({
+ type: z.literal("scale"),
+ scale: z.array(z.number()).min(2),
+ }),
+ z.object({
+ type: z.literal("translation"),
+ translation: z.array(z.number()).min(2),
+ }),
+ ];
+ const errors = schemas.reduce(
+ (errors, schema) =>
+ ((result) =>
+ result.error ? [...errors, result.error] : errors)(
+ schema.safeParse(x)
+ ),
+ []
+ );
+ if (schemas.length - errors.length !== 1) {
+ ctx.addIssue({
+ path: ctx.path,
+ code: "invalid_union",
+ unionErrors: errors,
+ message: "Invalid input: Should pass single schema",
+ });
+ }
+ })
+ )
+ .min(1),
+ })
+ )
+ .min(1),
+ version: z.literal("0.4").optional(),
+ axes: z
+ .array(
+ z.any().superRefine((x, ctx) => {
+ const schemas = [
+ z.object({
+ name: z.string(),
+ type: z.enum(["channel", "time", "space"]),
+ }),
+ z.object({
+ name: z.string(),
+ type: z
+ .any()
+ .refine(
+ (value) =>
+ !z.enum(["space", "time", "channel"]).safeParse(value)
+ .success,
+ "Invalid input: Should NOT be valid against schema"
+ )
+ .optional(),
+ }),
+ ];
+ const errors = schemas.reduce(
+ (errors, schema) =>
+ ((result) =>
+ result.error ? [...errors, result.error] : errors)(
+ schema.safeParse(x)
+ ),
+ []
+ );
+ if (schemas.length - errors.length !== 1) {
+ ctx.addIssue({
+ path: ctx.path,
+ code: "invalid_union",
+ unionErrors: errors,
+ message: "Invalid input: Should pass single schema",
+ });
+ }
+ })
+ )
+ .min(2)
+ .max(5),
+ coordinateTransformations: z
+ .array(
+ z.any().superRefine((x, ctx) => {
+ const schemas = [
+ z.object({
+ type: z.literal("scale"),
+ scale: z.array(z.number()).min(2),
+ }),
+ z.object({
+ type: z.literal("translation"),
+ translation: z.array(z.number()).min(2),
+ }),
+ ];
+ const errors = schemas.reduce(
+ (errors, schema) =>
+ ((result) =>
+ result.error ? [...errors, result.error] : errors)(
+ schema.safeParse(x)
+ ),
+ []
+ );
+ if (schemas.length - errors.length !== 1) {
+ ctx.addIssue({
+ path: ctx.path,
+ code: "invalid_union",
+ unionErrors: errors,
+ message: "Invalid input: Should pass single schema",
+ });
+ }
+ })
+ )
+ .min(1)
+ .optional(),
+ })
+ )
+ .min(1)
+ .describe("The multiscale datasets for this image"),
+ omero: z
+ .object({
+ channels: z.array(
+ z.object({
+ window: z.object({
+ end: z.number(),
+ max: z.number(),
+ min: z.number(),
+ start: z.number(),
+ }),
+ label: z.string().optional(),
+ family: z.string().optional(),
+ color: z.string(),
+ active: z.boolean().optional(),
+ })
+ ),
+ })
+ .optional(),
+ })
+ .describe("JSON from OME-NGFF .zattrs");
+export type Image = z.infer;
diff --git a/packages/core/src/data/ome_ngff/0.4/index.ts b/packages/core/src/data/ome_ngff/0.4/index.ts
new file mode 100644
index 00000000..b731a0b3
--- /dev/null
+++ b/packages/core/src/data/ome_ngff/0.4/index.ts
@@ -0,0 +1,6 @@
+export * from "./bf2raw";
+export * from "./image";
+export * from "./label";
+export * from "./ome";
+export * from "./plate";
+export * from "./well";
diff --git a/packages/core/src/data/ome_ngff/0.4/label.ts b/packages/core/src/data/ome_ngff/0.4/label.ts
new file mode 100644
index 00000000..251e4cc0
--- /dev/null
+++ b/packages/core/src/data/ome_ngff/0.4/label.ts
@@ -0,0 +1,56 @@
+import { z } from "zod";
+
+/**JSON from OME-NGFF .zattrs*/
+export const Label = z
+ .object({
+ "image-label": z
+ .object({
+ /**The colors for this label image*/
+ colors: z
+ .array(
+ z.object({
+ /**The value of the label*/
+ "label-value": z.number().describe("The value of the label"),
+ /**The RGBA color stored as an array of four integers between 0 and 255*/
+ rgba: z
+ .array(z.number().int().gte(0).lte(255))
+ .min(4)
+ .max(4)
+ .describe(
+ "The RGBA color stored as an array of four integers between 0 and 255"
+ )
+ .optional(),
+ })
+ )
+ .min(1)
+ .describe("The colors for this label image")
+ .optional(),
+ /**The properties for this label image*/
+ properties: z
+ .array(
+ z.object({
+ /**The pixel value for this label*/
+ "label-value": z
+ .number()
+ .int()
+ .describe("The pixel value for this label"),
+ })
+ )
+ .min(1)
+ .describe("The properties for this label image")
+ .optional(),
+ /**The source of this label image*/
+ source: z
+ .object({ image: z.string().optional() })
+ .describe("The source of this label image")
+ .optional(),
+ /**The version of the specification*/
+ version: z
+ .literal("0.4")
+ .describe("The version of the specification")
+ .optional(),
+ })
+ .optional(),
+ })
+ .describe("JSON from OME-NGFF .zattrs");
+export type Label = z.infer;
diff --git a/packages/core/src/data/ome_ngff/0.4/ome.ts b/packages/core/src/data/ome_ngff/0.4/ome.ts
new file mode 100644
index 00000000..f24b3306
--- /dev/null
+++ b/packages/core/src/data/ome_ngff/0.4/ome.ts
@@ -0,0 +1,15 @@
+import { z } from "zod";
+
+/**JSON from OME-NGFF OME/.zattrs linked to an OME-XML file*/
+export const Ome = z
+ .object({
+ /**An array of the same length and the same order as the images defined in the OME-XML*/
+ series: z
+ .array(z.string())
+ .describe(
+ "An array of the same length and the same order as the images defined in the OME-XML"
+ )
+ .optional(),
+ })
+ .describe("JSON from OME-NGFF OME/.zattrs linked to an OME-XML file");
+export type Ome = z.infer;
diff --git a/packages/core/src/data/ome_ngff/0.4/plate.ts b/packages/core/src/data/ome_ngff/0.4/plate.ts
new file mode 100644
index 00000000..13e05823
--- /dev/null
+++ b/packages/core/src/data/ome_ngff/0.4/plate.ts
@@ -0,0 +1,130 @@
+import { z } from "zod";
+
+/**JSON from OME-NGFF .zattrs*/
+export const Plate = z
+ .object({
+ plate: z
+ .object({
+ /**The acquisitions for this plate*/
+ acquisitions: z
+ .array(
+ z.object({
+ /**A unique identifier within the context of the plate*/
+ id: z
+ .number()
+ .int()
+ .gte(0)
+ .describe(
+ "A unique identifier within the context of the plate"
+ ),
+ /**The maximum number of fields of view for the acquisition*/
+ maximumfieldcount: z
+ .number()
+ .int()
+ .gt(0)
+ .describe(
+ "The maximum number of fields of view for the acquisition"
+ )
+ .optional(),
+ /**The name of the acquisition*/
+ name: z
+ .string()
+ .describe("The name of the acquisition")
+ .optional(),
+ /**The description of the acquisition*/
+ description: z
+ .string()
+ .describe("The description of the acquisition")
+ .optional(),
+ /**The start timestamp of the acquisition, expressed as epoch time i.e. the number seconds since the Epoch*/
+ starttime: z
+ .number()
+ .int()
+ .gte(0)
+ .describe(
+ "The start timestamp of the acquisition, expressed as epoch time i.e. the number seconds since the Epoch"
+ )
+ .optional(),
+ /**The end timestamp of the acquisition, expressed as epoch time i.e. the number seconds since the Epoch*/
+ endtime: z
+ .number()
+ .int()
+ .gte(0)
+ .describe(
+ "The end timestamp of the acquisition, expressed as epoch time i.e. the number seconds since the Epoch"
+ )
+ .optional(),
+ })
+ )
+ .describe("The acquisitions for this plate")
+ .optional(),
+ /**The version of the specification*/
+ version: z
+ .literal("0.4")
+ .describe("The version of the specification")
+ .optional(),
+ /**The maximum number of fields per view across all wells*/
+ field_count: z
+ .number()
+ .int()
+ .gt(0)
+ .describe("The maximum number of fields per view across all wells")
+ .optional(),
+ /**The name of the plate*/
+ name: z.string().describe("The name of the plate").optional(),
+ /**The columns of the plate*/
+ columns: z
+ .array(
+ z.object({
+ /**The column name*/
+ name: z
+ .string()
+ .regex(new RegExp("^[A-Za-z0-9]+$"))
+ .describe("The column name"),
+ })
+ )
+ .min(1)
+ .describe("The columns of the plate"),
+ /**The rows of the plate*/
+ rows: z
+ .array(
+ z.object({
+ /**The row name*/
+ name: z
+ .string()
+ .regex(new RegExp("^[A-Za-z0-9]+$"))
+ .describe("The row name"),
+ })
+ )
+ .min(1)
+ .describe("The rows of the plate"),
+ /**The wells of the plate*/
+ wells: z
+ .array(
+ z.object({
+ /**The path to the well subgroup*/
+ path: z
+ .string()
+ .regex(new RegExp("^[A-Za-z0-9]+/[A-Za-z0-9]+$"))
+ .describe("The path to the well subgroup"),
+ /**The index of the well in the rows list*/
+ rowIndex: z
+ .number()
+ .int()
+ .gte(0)
+ .describe("The index of the well in the rows list"),
+ /**The index of the well in the columns list*/
+ columnIndex: z
+ .number()
+ .int()
+ .gte(0)
+ .describe("The index of the well in the columns list"),
+ })
+ )
+ .min(1)
+ .describe("The wells of the plate"),
+ })
+ .optional(),
+ })
+ .describe("JSON from OME-NGFF .zattrs");
+export type Plate = z.infer;
diff --git a/packages/core/src/data/ome_ngff/0.4/well.ts b/packages/core/src/data/ome_ngff/0.4/well.ts
new file mode 100644
index 00000000..03dc23d0
--- /dev/null
+++ b/packages/core/src/data/ome_ngff/0.4/well.ts
@@ -0,0 +1,36 @@
+import { z } from "zod";
+
+/**JSON from OME-NGFF .zattrs*/
+export const Well = z
+ .object({
+ well: z
+ .object({
+ /**The fields of view for this well*/
+ images: z
+ .array(
+ z.object({
+ /**A unique identifier within the context of the plate*/
+ acquisition: z
+ .number()
+ .int()
+ .describe("A unique identifier within the context of the plate")
+ .optional(),
+ /**The path for this field of view subgroup*/
+ path: z
+ .string()
+ .regex(new RegExp("^[A-Za-z0-9]+$"))
+ .describe("The path for this field of view subgroup"),
+ })
+ )
+ .min(1)
+ .describe("The fields of view for this well"),
+ /**The version of the specification*/
+ version: z
+ .literal("0.4")
+ .describe("The version of the specification")
+ .optional(),
+ })
+ .optional(),
+ })
+ .describe("JSON from OME-NGFF .zattrs");
+export type Well = z.infer;
diff --git a/packages/core/src/data/ome_zarr_hcs_metadata_loader.ts b/packages/core/src/data/ome_zarr_hcs_metadata_loader.ts
new file mode 100644
index 00000000..bc2e9a74
--- /dev/null
+++ b/packages/core/src/data/ome_zarr_hcs_metadata_loader.ts
@@ -0,0 +1,20 @@
+import * as zarr from "zarrita";
+import { Plate } from "../data/ome_ngff/0.4/plate";
+import { Well } from "../data/ome_ngff/0.4/well";
+
+export async function loadOmeZarrPlate(url: string): Promise {
+ const store = new zarr.FetchStore(url);
+ const group = await zarr.open.v2(store, { kind: "group" });
+ // Will throw validation exceptions that we can catch if we want.
+ return Plate.parse(group.attrs);
+}
+
+export async function loadOmeZarrWell(
+ url: string,
+ path: string
+): Promise {
+ const store = new zarr.FetchStore(url + "/" + path);
+ const root = await zarr.open.v2(store, { kind: "group" });
+ // Will throw validation exceptions that we can catch if we want.
+ return Well.parse(root.attrs);
+}
diff --git a/packages/core/src/data/ome_zarr_image_loader.ts b/packages/core/src/data/ome_zarr_image_loader.ts
new file mode 100644
index 00000000..c4ed78cb
--- /dev/null
+++ b/packages/core/src/data/ome_zarr_image_loader.ts
@@ -0,0 +1,165 @@
+import * as zarr from "zarrita";
+import { Slice } from "@zarrita/indexing";
+
+import { Region } from "../data/region";
+import { ImageChunk, isImageChunkData } from "../data/image_chunk";
+import { isTextureUnpackRowAlignment } from "../objects/textures/texture";
+import { PromiseScheduler } from "./promise_scheduler";
+
+import { Image as OmeNgffImage } from "../data/ome_ngff/0.4/image";
+type Axis = OmeNgffImage["multiscales"][number]["axes"][number];
+
+// Implements the interface required for getting array chunks in zarrita:
+// https://github.com/manzt/zarrita.js/blob/c15c1a14e42a83516972368ac962ebdf56a6dcdb/packages/indexing/src/types.ts#L52
+export class PromiseQueue {
+ private promises_: Array<() => Promise> = [];
+ private scheduler_: PromiseScheduler;
+
+ constructor(scheduler: PromiseScheduler) {
+ this.scheduler_ = scheduler;
+ }
+
+ add(promise: () => Promise): void {
+ this.promises_.push(promise);
+ }
+
+ onIdle(): Promise> {
+ return Promise.all(this.promises_.map((p) => this.scheduler_.submit(p)));
+ }
+}
+
+// Loads chunks from a multiscale zarr image implementing OME-NGFF v0.4:
+// https://ngff.openmicroscopy.org/0.4/#image-layout
+export class OmeZarrImageLoader {
+ root_: zarr.Group;
+ metadata_: OmeNgffImage;
+
+ constructor(root: zarr.Group) {
+ this.root_ = root;
+ const attrs = this.root_.attrs;
+ // TODO: silly fix for removing top-level identity transform,
+ // which is not allowed by spec but may have been written by
+ // some writers.
+ // This may need to be done for top-level `coordinateTransformations` as well.
+ // https://github.com/ome/ngff/pull/152
+ if (
+ Array.isArray(attrs?.multiscales) &&
+ Array.isArray(attrs.multiscales[0]?.coordinateTransformations) &&
+ attrs.multiscales[0].coordinateTransformations[0]?.type === "identity"
+ ) {
+ delete attrs.multiscales[0].coordinateTransformations;
+ }
+ this.metadata_ = OmeNgffImage.parse(this.root_.attrs);
+ if (this.metadata_.multiscales.length !== 1) {
+ throw new Error(
+ `Can only handle one multiscale image. Found ${this.metadata_.multiscales.length}`
+ );
+ }
+ }
+
+ async loadChunk(
+ region: Region,
+ scheduler?: PromiseScheduler
+ ): Promise {
+ const image = this.metadata_.multiscales[0];
+ // TODO: use the input to determine what level to load.
+ // https://github.com/chanzuckerberg/imaging-active-learning/issues/37
+ const lowestResolutionIndex = image.datasets.length - 1;
+ const dataset = image.datasets[lowestResolutionIndex];
+ const scale = dataset.coordinateTransformations[0].scale;
+ // TODO: fix zod to generate this type information.
+ const axes = image.axes;
+ const translation =
+ dataset.coordinateTransformations.length === 2
+ ? dataset.coordinateTransformations[1].translation
+ : new Array(axes.length).fill(0);
+
+ const indices = regionToIndices(region, axes, scale, translation);
+ console.debug("loading dataset with indices", dataset, indices);
+
+ const array = await zarr.open.v2(this.root_.resolve(dataset.path), {
+ kind: "array",
+ attrs: false,
+ });
+
+ let options = {};
+ if (scheduler !== undefined) {
+ options = {
+ create_queue: () => new PromiseQueue(scheduler),
+ opts: { signal: scheduler.abortSignal },
+ };
+ }
+ const subarray = await zarr.get(array, indices, options);
+
+ if (!isImageChunkData(subarray.data)) {
+ throw new Error(
+ `Subarray has an unsupported data type ${subarray.data.constructor.name}`
+ );
+ }
+
+ if (subarray.shape.length !== 2 && subarray.shape.length !== 3) {
+ throw new Error(
+ `Expected to receive a 2D or 3D subarray. Instead chunk has shape ${subarray.shape}`
+ );
+ }
+
+ const rowAlignment = subarray.data.BYTES_PER_ELEMENT;
+ if (!isTextureUnpackRowAlignment(rowAlignment)) {
+ throw new Error(
+ "Invalid row alignment value. Possible values are 1, 2, 4, or 8"
+ );
+ }
+
+ const calculateOffset = (i: number) => {
+ const index = indices[i];
+ if (typeof index === "number" || index.start === null) return 0;
+ return index.start * scale[i] + translation[i];
+ };
+ const xOffset = calculateOffset(indices.length - 1);
+ const yOffset = calculateOffset(indices.length - 2);
+
+ const chunk = {
+ data: subarray.data,
+ shape: {
+ x: subarray.shape[subarray.shape.length - 1],
+ y: subarray.shape[subarray.shape.length - 2],
+ c: subarray.shape.length === 3 ? subarray.shape[0] : 1,
+ },
+ rowStride: subarray.stride[subarray.stride.length - 2],
+ rowAlignmentBytes: rowAlignment,
+ scale: { x: scale[indices.length - 1], y: scale[indices.length - 2] },
+ offset: { x: xOffset, y: yOffset },
+ };
+ console.debug("loaded chunk ", chunk);
+ return chunk;
+ }
+}
+
+// Converts a region to indices within an OME-Zarr image array.
+function regionToIndices(
+ region: Region,
+ axes: Array,
+ scale: number[],
+ translation: number[]
+): Array {
+ const indices: Array = [];
+ for (const [i, axis] of Array.from(axes.entries())) {
+ const match = region.find((s) => s.dimension == axis.name);
+ // If a match was not found use a null slice which represents
+ // the complete extent of a dimension like Python's `slice(None)`.
+ let index: Slice | number = zarr.slice(null);
+ if (match) {
+ const regionIndex = match.index;
+ if (typeof regionIndex === "number") {
+ index = Math.round(translation[i] + regionIndex / scale[i]);
+ } else {
+ index = zarr.slice(
+ Math.floor(translation[i] + regionIndex.start / scale[i]),
+ Math.ceil(translation[i] + regionIndex.stop / scale[i])
+ );
+ }
+ }
+ indices.push(index);
+ }
+ return indices;
+}
diff --git a/packages/core/src/data/ome_zarr_image_source.ts b/packages/core/src/data/ome_zarr_image_source.ts
new file mode 100644
index 00000000..8e142eef
--- /dev/null
+++ b/packages/core/src/data/ome_zarr_image_source.ts
@@ -0,0 +1,18 @@
+import * as zarr from "zarrita";
+import { OmeZarrImageLoader } from "src/data/ome_zarr_image_loader";
+
+// Opens an OME-Zarr multiscale image from the URL of its Zarr group.
+export class OmeZarrImageSource {
+ url_: string;
+
+ constructor(url: string) {
+ this.url_ = url;
+ }
+
+ async open(): Promise {
+ const store = new zarr.FetchStore(this.url_);
+ const root = await zarr.open.v2(store, { kind: "group" });
+ console.debug("opened root ", root, root.attrs);
+ return new OmeZarrImageLoader(root);
+ }
+}
diff --git a/packages/core/src/data/promise_scheduler.ts b/packages/core/src/data/promise_scheduler.ts
new file mode 100644
index 00000000..ab926204
--- /dev/null
+++ b/packages/core/src/data/promise_scheduler.ts
@@ -0,0 +1,68 @@
+export class AbortError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "AbortError";
+ // Manually adjust the prototype to handle sub-classing Error
+ // https://github.com/microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work
+ Object.setPrototypeOf(this, AbortError.prototype);
+ }
+}
+
+// Executes a limited number of promises concurrently.
+export class PromiseScheduler {
+ private readonly maxConcurrent_: number;
+ private readonly pending_: Array<() => Promise> = [];
+ private readonly abortController_ = new AbortController();
+ private numRunning_ = 0;
+
+ constructor(maxConcurrent: number) {
+ if (maxConcurrent <= 0) {
+ throw Error(`maxConcurrent (${maxConcurrent}) must be positive`);
+ }
+ this.maxConcurrent_ = maxConcurrent;
+ }
+
+ async submit(task: () => Promise): Promise {
+ this.abortController_.signal.throwIfAborted();
+ return new Promise((resolve, reject) => {
+ const promise = async () => {
+ try {
+ this.abortController_.signal.throwIfAborted();
+ const result = await task();
+ resolve(result);
+ } catch (error) {
+ reject(error);
+ } finally {
+ this.numRunning_--;
+ this.maybeRunNext();
+ }
+ };
+ this.pending_.push(promise);
+ this.maybeRunNext();
+ });
+ }
+
+ private maybeRunNext(): void {
+ if (this.numRunning_ >= this.maxConcurrent_) return;
+ const promise = this.pending_.shift();
+ if (promise === undefined) return;
+ this.numRunning_++;
+ promise();
+ }
+
+ get abortSignal() {
+ return this.abortController_.signal;
+ }
+
+ shutdown() {
+ this.abortController_.abort(new AbortError("shutdown"));
+ }
+
+ get numRunning() {
+ return this.numRunning_;
+ }
+
+ get numPending() {
+ return this.pending_.length;
+ }
+}
diff --git a/packages/core/src/data/region.ts b/packages/core/src/data/region.ts
new file mode 100644
index 00000000..32c7e4be
--- /dev/null
+++ b/packages/core/src/data/region.ts
@@ -0,0 +1,16 @@
+// The semi-closed interval [start, stop).
+export type Interval = {
+ start: number;
+ stop: number;
+};
+
+// An index for a specific dimension or axis in a region.
+// TODO: add a unit for the index value(s).
+// https://github.com/chanzuckerberg/imaging-active-learning/issues/36
+export type DimensionalIndex = {
+ dimension: string;
+ index: Interval | number;
+};
+
+// A region of some dimensional space.
+export type Region = Array;
diff --git a/packages/core/src/global.d.ts b/packages/core/src/global.d.ts
new file mode 100644
index 00000000..35e0469e
--- /dev/null
+++ b/packages/core/src/global.d.ts
@@ -0,0 +1,4 @@
+declare module "*.glsl" {
+ const value: string;
+ export default value;
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
new file mode 100644
index 00000000..7d21e422
--- /dev/null
+++ b/packages/core/src/index.ts
@@ -0,0 +1,5 @@
+export * from "./core";
+export * from "./data";
+export * from "./layers";
+export * from "./objects";
+export * from "./renderers";
diff --git a/packages/core/src/layers/axes_layer.ts b/packages/core/src/layers/axes_layer.ts
new file mode 100644
index 00000000..56d5afd5
--- /dev/null
+++ b/packages/core/src/layers/axes_layer.ts
@@ -0,0 +1,48 @@
+import { Layer } from "src/core/layer";
+import { ProjectedLineGeometry } from "../objects/geometry/projected_line_geometry";
+import { ProjectedLine } from "../objects/renderable/projected_line";
+
+export class AxesLayer extends Layer {
+ constructor(params: { length: number; width: number }) {
+ super();
+ const { length, width } = params;
+ this.addObject(
+ makeAxis({
+ end: [length, 0, 0],
+ width: width,
+ color: [1, 0, 0],
+ })
+ );
+ this.addObject(
+ makeAxis({
+ end: [0, length, 0],
+ width: width,
+ color: [0, 1, 0],
+ })
+ );
+ this.addObject(
+ makeAxis({
+ end: [0, 0, length],
+ width: width,
+ color: [0, 0, 1],
+ })
+ );
+ this.setState("ready");
+ }
+
+ public update(): void {}
+}
+
+function makeAxis(params: {
+ end: [number, number, number];
+ width: number;
+ color: [number, number, number];
+}) {
+ const { end, width, color } = params;
+ const geometry = new ProjectedLineGeometry([[0, 0, 0], end]);
+ return new ProjectedLine({
+ geometry: geometry,
+ color: color,
+ width: width,
+ });
+}
diff --git a/packages/core/src/layers/image_layer.ts b/packages/core/src/layers/image_layer.ts
new file mode 100644
index 00000000..48cfd11b
--- /dev/null
+++ b/packages/core/src/layers/image_layer.ts
@@ -0,0 +1,80 @@
+import { Layer } from "src/core/layer";
+import { Region } from "../data/region";
+import { ImageChunkSource } from "../data/image_chunk";
+import { Texture2DArray } from "../objects/textures/texture_2d_array";
+import {
+ makeImageTextureArray,
+ makeImageRenderable,
+} from "../layers/image_utils";
+import { ChannelProps } from "../objects/textures/channel";
+import { ImageRenderable } from "../objects/renderable/image_renderable";
+
+export type ImageLayerProps = {
+ source: ImageChunkSource;
+ region: Region;
+ channelProps?: ChannelProps[];
+};
+
+// Loads data from an image source into renderable objects.
+export class ImageLayer extends Layer {
+ private readonly source_: ImageChunkSource;
+
+ // TODO: remove this when region is passed through to update.
+ // https://github.com/chanzuckerberg/imaging-active-learning/issues/33
+ private readonly region_: Region;
+
+ private channelProps_?: ChannelProps[];
+
+ private texture_?: Texture2DArray;
+
+ private renderable_?: ImageRenderable;
+
+ constructor({ source, region, channelProps }: ImageLayerProps) {
+ super();
+ this.setState("initialized");
+ this.source_ = source;
+ this.region_ = region;
+ this.channelProps_ = channelProps;
+ }
+
+ public update(): void {
+ switch (this.state) {
+ case "initialized":
+ this.load(this.region_);
+ break;
+ case "loading":
+ case "ready":
+ break;
+ default: {
+ const exhaustiveCheck: never = this.state;
+ throw new Error(`Unhandled LayerState case: ${exhaustiveCheck}`);
+ }
+ }
+ }
+
+ public get channelProps(): ChannelProps[] | undefined {
+ return this.channelProps_;
+ }
+
+ public setChannelProps(channelProps: ChannelProps[]): void {
+ this.channelProps_ = channelProps;
+ this.renderable_?.setChannelProps(channelProps);
+ }
+
+ private async load(region: Region) {
+ if (this.state !== "initialized") {
+ throw new Error(`Trying to load chunks more than once.`);
+ }
+ this.setState("loading");
+ const loader = await this.source_.open();
+ const chunk = await loader.loadChunk(region);
+ this.texture_ = makeImageTextureArray(chunk);
+ this.renderable_ = makeImageRenderable(
+ chunk,
+ this.texture_,
+ this.channelProps_
+ );
+ this.addObject(this.renderable_);
+ this.setState("ready");
+ }
+}
diff --git a/packages/core/src/layers/image_series_layer.ts b/packages/core/src/layers/image_series_layer.ts
new file mode 100644
index 00000000..7b101586
--- /dev/null
+++ b/packages/core/src/layers/image_series_layer.ts
@@ -0,0 +1,150 @@
+import { Layer } from "src/core/layer";
+import { Interval, Region } from "../data/region";
+import { ImageChunk, ImageChunkSource } from "../data/image_chunk";
+import { Texture2DArray } from "../objects/textures/texture_2d_array";
+import { AbortError, PromiseScheduler } from "../data/promise_scheduler";
+import { makeImageTextureArray, makeImageRenderable } from "./image_utils";
+import { ChannelProps } from "../objects/textures/channel";
+import { ImageRenderable } from "../objects/renderable/image_renderable";
+
+type ImageSeriesLayerProps = {
+ source: ImageChunkSource;
+ region: Region;
+ timeDimension: string;
+ channelProps?: ChannelProps[];
+};
+
+// Loads 2D+t image data from an image source into renderable objects.
+export class ImageSeriesLayer extends Layer {
+ private readonly source_: ImageChunkSource;
+
+ private readonly region_: Region;
+
+ private readonly timeInterval_: Interval;
+
+ private readonly timeDimensionIndex_: number;
+
+ private texture_: Texture2DArray | null = null;
+
+ private dataChunks_: ImageChunk[] = [];
+
+ private scheduler_: PromiseScheduler = new PromiseScheduler(16);
+
+ private channelProps_?: ChannelProps[];
+
+ private renderable_?: ImageRenderable;
+
+ constructor({
+ source,
+ region,
+ timeDimension,
+ channelProps,
+ }: ImageSeriesLayerProps) {
+ super();
+ this.setState("initialized");
+ this.source_ = source;
+ this.region_ = region;
+ this.timeDimensionIndex_ = region.findIndex(
+ (x) => x.dimension == timeDimension
+ );
+ if (this.timeDimensionIndex_ === -1) {
+ throw new Error(
+ `Could not find dimension ${timeDimension} in ${JSON.stringify(region)}`
+ );
+ }
+ const timeIndex = this.region_[this.timeDimensionIndex_].index;
+ if (typeof timeIndex === "number") {
+ throw new Error(
+ `Time index is a number (${timeIndex}). It should be an interval.`
+ );
+ }
+ this.timeInterval_ = timeIndex;
+ this.channelProps_ = channelProps;
+ }
+
+ public get channelProps(): ChannelProps[] | undefined {
+ return this.channelProps_;
+ }
+
+ public setChannelProps(channelProps: ChannelProps[]): void {
+ this.channelProps_ = channelProps;
+ this.renderable_?.setChannelProps(channelProps);
+ }
+
+ public update(): void {
+ switch (this.state) {
+ case "initialized":
+ this.load();
+ break;
+ case "loading":
+ case "ready":
+ break;
+ default: {
+ const exhaustiveCheck: never = this.state;
+ throw new Error(`Unhandled LayerState case: ${exhaustiveCheck}`);
+ }
+ }
+ }
+
+ public setTimeIndex(index: number) {
+ if (this.state !== "ready") {
+ console.warn(`Trying to set time index before ready: ${this.state}`);
+ return;
+ }
+ const { start, stop } = this.timeInterval_;
+ const chunkIndex = index - start;
+ if (chunkIndex < 0) {
+ throw new Error(`Time index ${index} is before the start time: ${start}`);
+ } else if (chunkIndex >= this.dataChunks_.length) {
+ throw new Error(`Time index ${index} is after the stop time: ${stop}.`);
+ }
+ const chunk = this.dataChunks_[chunkIndex];
+ if (this.texture_ === null) {
+ this.texture_ = makeImageTextureArray(chunk);
+ this.renderable_ = makeImageRenderable(
+ chunk,
+ this.texture_,
+ this.channelProps_
+ );
+ this.addObject(this.renderable_);
+ } else {
+ this.texture_.data = chunk.data;
+ }
+ }
+
+ public close(): void {
+ this.scheduler_.shutdown();
+ }
+
+ private async load() {
+ if (this.state !== "initialized") {
+ throw new Error(`Trying to open chunk loader more than once.`);
+ }
+ this.setState("loading");
+ const loader = await this.source_.open();
+ // Wait to load the whole region over all time points.
+ this.dataChunks_ = [];
+ const loadPromises = [];
+ const { start, stop } = this.timeInterval_;
+ // TODO: this assumes that time-steps are unitary when they might
+ // have a scale associated with them. We could instead load the
+ // whole region in and map back the chunks appropriately.
+ // https://github.com/chanzuckerberg/imaging-active-learning/issues/75
+ for (let t = start; t < stop; ++t) {
+ const region = structuredClone(this.region_);
+ region[this.timeDimensionIndex_].index = t;
+ loadPromises.push(
+ loader
+ .loadChunk(region, this.scheduler_)
+ .then((chunk) => (this.dataChunks_[t - start] = chunk))
+ );
+ }
+ await Promise.all(loadPromises).catch((error) => {
+ if (error instanceof AbortError) {
+ console.debug("Loading aborted.");
+ return;
+ }
+ });
+ this.setState("ready");
+ }
+}
diff --git a/packages/core/src/layers/image_utils.ts b/packages/core/src/layers/image_utils.ts
new file mode 100644
index 00000000..e6d5df03
--- /dev/null
+++ b/packages/core/src/layers/image_utils.ts
@@ -0,0 +1,33 @@
+import { DataTexture2D } from "../objects/textures/data_texture_2d";
+import { Texture } from "../objects/textures/texture";
+import { Texture2DArray } from "../objects/textures/texture_2d_array";
+import { ImageChunk } from "../data/image_chunk";
+import { PlaneGeometry } from "../objects/geometry/plane_geometry";
+import { ChannelProps } from "../objects/textures/channel";
+import { ImageRenderable } from "../objects/renderable/image_renderable";
+
+export function makeImageTexture(chunk: ImageChunk) {
+ const texture = new DataTexture2D(chunk.data, chunk.shape.x, chunk.shape.y);
+ texture.unpackRowLength = chunk.rowStride;
+ texture.unpackAlignment = chunk.rowAlignmentBytes;
+ return texture;
+}
+
+export function makeImageTextureArray(chunk: ImageChunk) {
+ const texture = new Texture2DArray(chunk.data, chunk.shape.x, chunk.shape.y);
+ texture.unpackRowLength = chunk.rowStride;
+ texture.unpackAlignment = chunk.rowAlignmentBytes;
+ return texture;
+}
+
+export function makeImageRenderable(
+ chunk: ImageChunk,
+ texture: Texture,
+ channelProps?: ChannelProps[]
+): ImageRenderable {
+ const geometry = new PlaneGeometry(chunk.shape.x, chunk.shape.y, 1, 1);
+ const imageRenderable = new ImageRenderable(geometry, texture, channelProps);
+ imageRenderable.transform.scale([chunk.scale.x, chunk.scale.y, 1]);
+ imageRenderable.transform.translate([chunk.offset.x, chunk.offset.y, 0]);
+ return imageRenderable;
+}
diff --git a/packages/core/src/layers/index.ts b/packages/core/src/layers/index.ts
new file mode 100644
index 00000000..89067d57
--- /dev/null
+++ b/packages/core/src/layers/index.ts
@@ -0,0 +1,6 @@
+export * from "./axes_layer";
+export * from "./image_series_layer";
+export * from "./image_layer";
+export * from "./projected_line_layer";
+export * from "./single_mesh_layer";
+export * from "./tracks_layer";
diff --git a/packages/core/src/layers/projected_line_layer.ts b/packages/core/src/layers/projected_line_layer.ts
new file mode 100644
index 00000000..baddd0a6
--- /dev/null
+++ b/packages/core/src/layers/projected_line_layer.ts
@@ -0,0 +1,57 @@
+import { vec3 } from "gl-matrix";
+import { Layer } from "src/core/layer";
+import { ProjectedLine } from "../objects/renderable/projected_line";
+import { ProjectedLineGeometry } from "../objects/geometry/projected_line_geometry";
+
+type LineParameters = {
+ path: vec3[];
+ color: vec3;
+ width: number;
+};
+
+export class ProjectedLineLayer extends Layer {
+ private paths_: vec3[][] = [];
+
+ constructor(lines: LineParameters[] = []) {
+ super();
+ lines.forEach((line) => this.addLine(line));
+ this.setState("ready");
+ }
+
+ private addLine(line: LineParameters) {
+ const { path, color, width } = line;
+ this.paths_.push(path);
+ const geometry = new ProjectedLineGeometry(path);
+ this.addObject(new ProjectedLine({ geometry, color, width }));
+ }
+
+ public update(): void {}
+
+ // TODO: this is temporary - we may want to generalize this to all layers
+ // for now it is used to set the initial camera position to be centered on the tracks
+ public get extent() {
+ return getPathBoundingBox(this.paths_.flat());
+ }
+}
+
+type BoundingBox3D = {
+ xMin: number;
+ xMax: number;
+ yMin: number;
+ yMax: number;
+ zMin: number;
+ zMax: number;
+};
+
+function getPathBoundingBox(path: vec3[]): BoundingBox3D {
+ function getAxisBounds(index: number): [number, number] {
+ const values = path.map((point) => point[index]);
+ return [Math.min(...values), Math.max(...values)];
+ }
+
+ const [xMin, xMax] = getAxisBounds(0);
+ const [yMin, yMax] = getAxisBounds(1);
+ const [zMin, zMax] = getAxisBounds(2);
+
+ return { xMin, xMax, yMin, yMax, zMin, zMax };
+}
diff --git a/packages/core/src/layers/single_mesh_layer.ts b/packages/core/src/layers/single_mesh_layer.ts
new file mode 100644
index 00000000..01d6ef26
--- /dev/null
+++ b/packages/core/src/layers/single_mesh_layer.ts
@@ -0,0 +1,27 @@
+import { Layer } from "src/core/layer";
+import { ImageRenderable } from "../objects/renderable/image_renderable";
+import { PlaneGeometry } from "../objects/geometry/plane_geometry";
+import { Texture2D } from "../objects/textures/texture_2d";
+
+export class SingleMeshLayer extends Layer {
+ private texture_ = new Texture2D("/texture_test.png");
+
+ constructor() {
+ super();
+ this.setState("loading");
+ }
+
+ public update(): void {
+ if (this.texture_.loaded && this.state !== "ready") {
+ const plane = new PlaneGeometry(
+ this.texture_.width,
+ this.texture_.height,
+ 1,
+ 1
+ );
+ const mesh = new ImageRenderable(plane, this.texture_);
+ this.addObject(mesh);
+ this.setState("ready");
+ }
+ }
+}
diff --git a/packages/core/src/layers/tracks_layer.ts b/packages/core/src/layers/tracks_layer.ts
new file mode 100644
index 00000000..3c7d4852
--- /dev/null
+++ b/packages/core/src/layers/tracks_layer.ts
@@ -0,0 +1,167 @@
+import { vec3 } from "gl-matrix";
+import { Layer } from "src/core/layer";
+import { ProjectedLine } from "../objects/renderable/projected_line";
+import { ProjectedLineGeometry } from "../objects/geometry/projected_line_geometry";
+
+type TrackParameters = {
+ path: vec3[];
+ interpolation?: { pointsPerSegment: number; tangentFactor?: number };
+ time?: number[];
+ color: vec3;
+ width: number;
+};
+
+export class TracksLayer extends Layer {
+ private tracks_: TrackParameters[] = [];
+
+ constructor(tracks: TrackParameters[] = []) {
+ super();
+ tracks.forEach((track) => this.addLine(track));
+ this.setState("ready");
+ }
+
+ private addLine(track: TrackParameters) {
+ this.tracks_.push(track);
+ let geometry;
+ if (track.interpolation) {
+ const interpolatedPath = cubicBezierInterpolation({
+ path: track.path,
+ pointsPerSegment: track.interpolation.pointsPerSegment,
+ tangentFactor: track.interpolation.tangentFactor,
+ });
+ geometry = new ProjectedLineGeometry(interpolatedPath);
+ } else {
+ geometry = new ProjectedLineGeometry(track.path);
+ }
+ const { color, width } = track;
+ const taperOffset = 0.5;
+ const taperPower = 1.5;
+ this.addObject(
+ new ProjectedLine({ geometry, color, width, taperOffset, taperPower })
+ );
+ }
+
+ public setTimeIndex(index: number) {
+ for (const [i, track] of Array.from(this.tracks_.entries())) {
+ if (!track.time) {
+ continue;
+ }
+ let offset = 0.5;
+ if (index < track.time[0]) {
+ offset = -1.5;
+ } else if (index > track.time[track.time.length - 1]) {
+ offset = 1.5;
+ }
+ const timeIndex = track.time.findIndex((time) => time === index);
+ if (track.time && timeIndex !== -1) {
+ offset = timeIndex / (track.time.length - 1);
+ }
+ const object = this.objects[i] as ProjectedLine;
+ object.taperOffset = offset;
+ }
+ }
+
+ public update(): void {}
+
+ // TODO: this is temporary - we may want to generalize this to all layers
+ // for now it is used to set the initial camera position to be centered on the tracks
+ public get extent() {
+ const paths = this.tracks_.map((track) => track.path);
+ return getPathBoundingBox(paths.flat());
+ }
+}
+
+type BoundingBox3D = {
+ xMin: number;
+ xMax: number;
+ yMin: number;
+ yMax: number;
+ zMin: number;
+ zMax: number;
+};
+
+function getPathBoundingBox(path: vec3[]): BoundingBox3D {
+ function getAxisBounds(index: number): [number, number] {
+ const values = path.map((point) => point[index]);
+ return [Math.min(...values), Math.max(...values)];
+ }
+
+ const [xMin, xMax] = getAxisBounds(0);
+ const [yMin, yMax] = getAxisBounds(1);
+ const [zMin, zMax] = getAxisBounds(2);
+
+ return { xMin, xMax, yMin, yMax, zMin, zMax };
+}
+
+type BezierParams = {
+ path: vec3[];
+ pointsPerSegment: number;
+ tangentFactor?: number;
+};
+
+function cubicBezierInterpolation({
+ path,
+ pointsPerSegment,
+ tangentFactor = 1.0 / 3.0,
+}: BezierParams): vec3[] {
+ const tangents = pathTangents(path);
+
+ const out = Array((path.length - 1) * pointsPerSegment);
+
+ // a cubic bezier curve is defined by 4 control points: a, b, c, d
+ // for interpolation of a segment:
+ // * a and d are the endpoints of the curve segment
+ // * b and c are control points that define curvature
+ // default is 1/3 of the tangent at each endpoint as the control points
+ // this is equivalent to a cubic Hermite spline
+ for (let i = 0; i < path.length - 1; i++) {
+ const a = path[i];
+ const d = path[i + 1];
+ const b = vec3.clone(tangents[i]);
+ vec3.scaleAndAdd(b, a, b, tangentFactor);
+ const c = vec3.clone(tangents[i + 1]);
+ vec3.scaleAndAdd(c, d, c, -tangentFactor);
+ for (let p = 0; p < pointsPerSegment; p++) {
+ const t = p / pointsPerSegment;
+ const o = (out[i * pointsPerSegment + p] = vec3.create());
+ vec3.bezier(o, a, b, c, d, t);
+ }
+ }
+
+ return out;
+}
+
+function pathTangents(path: vec3[]): vec3[] {
+ if (path.length < 2) {
+ throw new Error("Path must contain at least 2 points");
+ }
+
+ const tangents: vec3[] = Array(path.length);
+ const m0 = vec3.create();
+ const m1 = vec3.create();
+ for (let i = 0; i < path.length; i++) {
+ const curr = path[i];
+ const next = path[i + 1] ?? path[i];
+
+ tangents[i] = vec3.create();
+
+ if (i !== 0) {
+ vec3.copy(m0, m1);
+ }
+
+ if (i !== path.length - 1) {
+ vec3.sub(m1, next, curr);
+ }
+
+ if (i === 0) {
+ vec3.copy(tangents[i], m1);
+ } else if (i == path.length - 1) {
+ vec3.copy(tangents[i], m0);
+ } else {
+ vec3.add(tangents[i], m0, m1);
+ vec3.scale(tangents[i], tangents[i], 0.5);
+ }
+ }
+
+ return tangents;
+}
diff --git a/packages/core/src/objects/cameras/camera.ts b/packages/core/src/objects/cameras/camera.ts
new file mode 100644
index 00000000..8881bcb2
--- /dev/null
+++ b/packages/core/src/objects/cameras/camera.ts
@@ -0,0 +1,54 @@
+import { RenderableObject } from "../../core/renderable_object";
+import { mat4, quat, vec3, vec4 } from "gl-matrix";
+
+export abstract class Camera extends RenderableObject {
+ protected projectionMatrix_ = mat4.create();
+ protected near_ = 0;
+ protected far_ = 0;
+ protected zoom_ = 1;
+
+ protected abstract updateProjectionMatrix(): void;
+
+ public get type() {
+ return "Camera";
+ }
+
+ public update() {
+ this.updateProjectionMatrix();
+ }
+
+ get projectionMatrix() {
+ return this.projectionMatrix_;
+ }
+
+ public get zoom() {
+ return this.zoom_;
+ }
+
+ public set zoom(zoom: number) {
+ this.zoom_ = zoom;
+ this.updateProjectionMatrix();
+ }
+
+ public pan(vec: vec3) {
+ this.transform.translate(vec);
+ }
+
+ public get position() {
+ return this.transform.translation;
+ }
+
+ public clipToWorld(position: vec3): vec3 {
+ const clipPos = vec4.fromValues(position[0], position[1], position[2], 1);
+ const projectionInverse = mat4.invert(mat4.create(), this.projectionMatrix);
+ const worldPos = vec4.transformMat4(
+ vec4.create(),
+ clipPos,
+ projectionInverse
+ );
+ const rotation = mat4.getRotation(quat.create(), this.transform.matrix);
+ vec4.transformQuat(worldPos, worldPos, rotation);
+ vec4.scale(worldPos, worldPos, 1 / worldPos[3]);
+ return vec3.fromValues(worldPos[0], worldPos[1], worldPos[2]);
+ }
+}
diff --git a/packages/core/src/objects/cameras/controls.ts b/packages/core/src/objects/cameras/controls.ts
new file mode 100644
index 00000000..41f15cea
--- /dev/null
+++ b/packages/core/src/objects/cameras/controls.ts
@@ -0,0 +1,120 @@
+import { vec2, vec3 } from "gl-matrix";
+import { Camera } from "./camera";
+
+type ClientToClip = (clientPos: vec2, depth: number) => vec3;
+type ClientToWorld = (clientPos: vec2, depth: number) => vec3;
+
+export interface CameraControls {
+ callbacks(
+ target: EventTarget,
+ clientToClip: ClientToClip
+ ): [string, EventListener][];
+}
+
+export class NullControls implements CameraControls {
+ public callbacks(
+ _target: EventTarget,
+ _clientToClip: ClientToClip
+ ): [string, EventListener][] {
+ return [];
+ }
+}
+
+export class PanZoomControls implements CameraControls {
+ private camera_: Camera;
+ private panTarget_: vec3;
+
+ constructor(camera: Camera, panTarget: vec3 = vec3.fromValues(0, 0, 0)) {
+ this.camera_ = camera;
+ this.panTarget_ = panTarget;
+ }
+
+ public callbacks(
+ target: EventTarget,
+ clientToClip: ClientToClip
+ ): [string, EventListener][] {
+ const clientToWorld: ClientToWorld = (clientPos, depth) => {
+ const clipPos = clientToClip(clientPos, depth);
+ return this.camera_.clipToWorld(clipPos);
+ };
+ return [
+ [
+ "wheel",
+ (event: Event) => this.wheel(event as WheelEvent, clientToWorld),
+ ],
+ [
+ "mousedown",
+ (event: Event) =>
+ this.mousedown(event as MouseEvent, target, clientToWorld),
+ ],
+ ];
+ }
+
+ private wheel(event: WheelEvent, clientToWorld: ClientToWorld): void {
+ const clientPos = vec2.fromValues(event.clientX, event.clientY);
+ const preZoomPos = clientToWorld(clientPos, this.clipDepth);
+ if (event.deltaY < 0) {
+ this.camera_.zoom *= 1.05;
+ } else {
+ this.camera_.zoom /= 1.05;
+ }
+ // pan to zoom in on the mouse position
+ const postZoomPos = clientToWorld(clientPos, this.clipDepth);
+ const deltaWorld = vec3.sub(vec3.create(), preZoomPos, postZoomPos);
+ this.pan(deltaWorld);
+ }
+
+ private mousedown(
+ event: MouseEvent,
+ target: EventTarget,
+ clientToWorld: ClientToWorld
+ ): void {
+ const clientStart = vec2.fromValues(event.clientX, event.clientY);
+ let worldStart = clientToWorld(clientStart, this.clipDepth);
+
+ const onMouseMove = (event: Event) => {
+ if (!(event instanceof MouseEvent)) {
+ throw new Error("Expected MouseEvent");
+ }
+ const clientPos = vec2.fromValues(event.clientX, event.clientY);
+ const worldPos = clientToWorld(clientPos, this.clipDepth);
+ const deltaWorld = vec3.sub(vec3.create(), worldStart, worldPos);
+ this.pan(deltaWorld);
+ worldStart = worldPos;
+ };
+
+ const onMouseUp = () => {
+ target.removeEventListener("mousemove", onMouseMove);
+ target.removeEventListener("mouseup", onMouseUp);
+ };
+
+ target.addEventListener("mousemove", onMouseMove);
+ target.addEventListener("mouseup", onMouseUp);
+ }
+
+ private pan(deltaWorld: vec3): void {
+ this.camera_.pan(deltaWorld);
+ vec3.add(this.panTarget_, this.panTarget_, deltaWorld);
+ }
+
+ public set panTarget(panTarget: vec3) {
+ this.panTarget_ = panTarget;
+ }
+
+ private get clipDepth() {
+ const targetToPosition = vec3.sub(
+ vec3.create(),
+ this.panTarget_,
+ this.camera_.position
+ );
+ const projectedViewVector = vec3.transformMat4(
+ vec3.create(),
+ targetToPosition,
+ this.camera_.projectionMatrix
+ );
+ // TODO: the distance should be projected onto the camera's view
+ // normal when we start rotating cameras
+ const distance = vec3.length(projectedViewVector);
+ return distance;
+ }
+}
diff --git a/packages/core/src/objects/cameras/orthographic_camera.ts b/packages/core/src/objects/cameras/orthographic_camera.ts
new file mode 100644
index 00000000..3a24fb11
--- /dev/null
+++ b/packages/core/src/objects/cameras/orthographic_camera.ts
@@ -0,0 +1,80 @@
+import { Camera } from "./camera";
+import { mat4 } from "gl-matrix";
+
+export class OrthographicCamera extends Camera {
+ private width_: number;
+ private height_: number;
+ private viewportAspectRatio_: number = 1;
+
+ constructor(
+ left: number,
+ right: number,
+ top: number,
+ bottom: number,
+ near = 0,
+ far = 100.0
+ ) {
+ super();
+
+ // this keeps the camera frame centered at the origin
+ // use camera.pan or set its *position* (this.transform) to move the center
+ this.width_ = Math.abs(right - left);
+ this.height_ = Math.abs(top - bottom);
+ const centerX = 0.5 * (left + right);
+ const centerY = 0.5 * (bottom + top);
+ this.transform.setTranslation([centerX, centerY, 0]);
+ this.setFrame(left, right, bottom, top);
+
+ this.near_ = near;
+ this.far_ = far;
+
+ this.updateProjectionMatrix();
+ }
+
+ public setViewportAspectRatio(aspectRatio: number) {
+ this.viewportAspectRatio_ = aspectRatio;
+ }
+
+ public setFrame(left: number, right: number, bottom: number, top: number) {
+ this.width_ = Math.abs(right - left);
+ this.height_ = Math.abs(top - bottom);
+ const centerX = 0.5 * (left + right);
+ const centerY = 0.5 * (bottom + top);
+ this.transform.setTranslation([centerX, centerY, 0]);
+ this.zoom_ = 1.0;
+ }
+
+ public get type() {
+ return "OrthographicCamera";
+ }
+
+ protected updateProjectionMatrix() {
+ // The following code ensures that the orthographic projection matrix
+ // is updated so that the aspect ratio of renderable objects is respected
+ // (e.g. image pixels are isotropic) by padding the camera frame to form
+ // the viewport frame.
+ const width = this.width_ / this.zoom_;
+ const height = this.height_ / this.zoom_;
+ const frameAspectRatio = width / height;
+ // When the viewport is wider than the camera frame, add horizontal
+ // padding such that the height is unchanged. Otherwise, add vertical
+ // padding such that the width is unchanged.
+ let viewportHalfWidth = 0.5 * width;
+ let viewportHalfHeight = 0.5 * height;
+ if (this.viewportAspectRatio_ > frameAspectRatio) {
+ viewportHalfWidth *= this.viewportAspectRatio_ / frameAspectRatio;
+ } else {
+ viewportHalfHeight *= frameAspectRatio / this.viewportAspectRatio_;
+ }
+ // Center the camera frame in the padded viewport frame.
+ mat4.ortho(
+ this.projectionMatrix_,
+ -viewportHalfWidth,
+ viewportHalfWidth,
+ -viewportHalfHeight,
+ viewportHalfHeight,
+ this.near_,
+ this.far_
+ );
+ }
+}
diff --git a/packages/core/src/objects/cameras/perspective_camera.ts b/packages/core/src/objects/cameras/perspective_camera.ts
new file mode 100644
index 00000000..d87e3909
--- /dev/null
+++ b/packages/core/src/objects/cameras/perspective_camera.ts
@@ -0,0 +1,70 @@
+import { Camera } from "./camera";
+import { glMatrix, mat4, vec3 } from "gl-matrix";
+
+const DEFAULT_FOV = 60; // degrees
+const DEFAULT_ASPECT_RATIO = 1.77; // 16:9
+
+type PerspectiveCameraOptions = {
+ fov?: number;
+ aspectRatio?: number;
+ near?: number;
+ far?: number;
+ position?: vec3;
+};
+
+export class PerspectiveCamera extends Camera {
+ private fov_: number;
+ private aspectRatio_: number;
+
+ constructor(options: PerspectiveCameraOptions = {}) {
+ const {
+ fov = DEFAULT_FOV,
+ aspectRatio = DEFAULT_ASPECT_RATIO,
+ near = 0.1,
+ far = 10000,
+ position = vec3.fromValues(0, 0, 0),
+ } = options;
+
+ if (fov <= 0 || fov >= 180) {
+ throw new Error(`Invalid field of view: ${fov}`);
+ }
+ super();
+ this.fov_ = fov;
+ this.aspectRatio_ = aspectRatio;
+ this.near_ = near;
+ this.far_ = far;
+
+ this.transform.setTranslation(position);
+
+ this.updateProjectionMatrix();
+ }
+
+ public setAspectRatio(aspectRatio: number) {
+ this.aspectRatio_ = aspectRatio;
+ }
+
+ public get type() {
+ return "PerspectiveCamera";
+ }
+
+ public get fov() {
+ return this.fov_;
+ }
+
+ public get effectiveFov() {
+ return this.fov_ / this.zoom_;
+ }
+
+ protected updateProjectionMatrix() {
+ // clamp the field of view and zoom to prevent degenerate behavior
+ const fov = Math.max(0.1, Math.min(179.9, this.fov_ / this.zoom_));
+ this.zoom_ = this.fov_ / fov;
+ mat4.perspective(
+ this.projectionMatrix_,
+ glMatrix.toRadian(fov),
+ this.aspectRatio_,
+ this.near_,
+ this.far_
+ );
+ }
+}
diff --git a/packages/core/src/objects/geometry/plane_geometry.ts b/packages/core/src/objects/geometry/plane_geometry.ts
new file mode 100644
index 00000000..a245064f
--- /dev/null
+++ b/packages/core/src/objects/geometry/plane_geometry.ts
@@ -0,0 +1,69 @@
+import { Geometry } from "../../core/geometry";
+
+export class PlaneGeometry extends Geometry {
+ constructor(
+ width: number,
+ height: number,
+ widthSegments: number,
+ heightSegments: number
+ ) {
+ super();
+ const vertex: number[] = [];
+ const index: number[] = [];
+
+ const gridX = widthSegments;
+ const gridY = heightSegments;
+ const gridX1 = gridX + 1;
+ const gridY1 = gridY + 1;
+ const segmentW = width / gridX;
+ const segmentH = height / gridY;
+
+ for (let iy = 0; iy < gridY1; ++iy) {
+ const y = iy * segmentH;
+ for (let ix = 0; ix < gridX1; ++ix) {
+ const x = ix * segmentW;
+ const u = ix / gridX;
+ const v = iy / gridY;
+
+ const position = [x, y, 0];
+ const normals = [0, 0, 1];
+ const uvs = [u, v];
+
+ vertex.push(...position, ...normals, ...uvs);
+ }
+ }
+
+ for (let iy = 0; iy < gridY; ++iy) {
+ for (let ix = 0; ix < gridX; ++ix) {
+ const a = ix + gridX1 * iy;
+ const b = ix + gridX1 * (iy + 1);
+ const c = ix + 1 + gridX1 * (iy + 1);
+ const d = ix + 1 + gridX1 * iy;
+
+ index.push(a, b, d);
+ index.push(b, c, d);
+ }
+ }
+
+ this.vertexData_ = new Float32Array(vertex);
+ this.indexData_ = new Uint32Array(index);
+
+ this.addAttribute({
+ type: "position",
+ itemSize: 3,
+ offset: 0,
+ });
+
+ this.addAttribute({
+ type: "normal",
+ itemSize: 3,
+ offset: 3 * Float32Array.BYTES_PER_ELEMENT,
+ });
+
+ this.addAttribute({
+ type: "uv",
+ itemSize: 2,
+ offset: 6 * Float32Array.BYTES_PER_ELEMENT,
+ });
+ }
+}
diff --git a/packages/core/src/objects/geometry/projected_line_geometry.ts b/packages/core/src/objects/geometry/projected_line_geometry.ts
new file mode 100644
index 00000000..d7e36271
--- /dev/null
+++ b/packages/core/src/objects/geometry/projected_line_geometry.ts
@@ -0,0 +1,103 @@
+import { vec3 } from "gl-matrix";
+
+import { Geometry } from "../../core/geometry";
+
+export class ProjectedLineGeometry extends Geometry {
+ // this creates the geometry for a screen-space projected line
+ // each point on the input path is split into two vertices
+ // these are pushed in opposite directions in screen-space to create width
+ // this is done in the vertex shader by moving the vertices along the path normal
+ // See:
+ // https://mattdesl.svbtle.com/drawing-lines-is-hard#screenspace-projected-lines_2
+ // https://github.com/spite/THREE.MeshLine
+ constructor(path: vec3[]) {
+ super();
+ this.vertexData_ = this.createVertices(path);
+ this.indexData_ = this.createIndex(path.length);
+ this.addAttribute({
+ type: "position",
+ itemSize: 3,
+ offset: 0,
+ });
+ this.addAttribute({
+ type: "previous_position",
+ itemSize: 3,
+ offset: 3 * Float32Array.BYTES_PER_ELEMENT,
+ });
+ this.addAttribute({
+ type: "next_position",
+ itemSize: 3,
+ offset: 6 * Float32Array.BYTES_PER_ELEMENT,
+ });
+ this.addAttribute({
+ type: "direction",
+ itemSize: 1,
+ offset: 9 * Float32Array.BYTES_PER_ELEMENT,
+ });
+ this.addAttribute({
+ type: "path_proportion",
+ itemSize: 1,
+ offset: 10 * Float32Array.BYTES_PER_ELEMENT,
+ });
+ }
+
+ private createVertices(path: vec3[]): Float32Array {
+ const vertices = new Float32Array(2 * path.length * (3 + 3 + 3 + 1 + 1));
+
+ let c = 0;
+ let path_proportion = 0.0;
+ const total_distance = path.reduce((acc, curr, i) => {
+ return acc + vec3.distance(curr, path[i + 1] ?? curr);
+ }, 0.0);
+ for (const i of Array.from(Array(path.length).keys())) {
+ for (const direction of [-1.0, 1.0]) {
+ const current = path[i];
+ vertices[c++] = current[0];
+ vertices[c++] = current[1];
+ vertices[c++] = current[2];
+
+ const previous = path[i - 1] ?? path[i];
+ vertices[c++] = previous[0];
+ vertices[c++] = previous[1];
+ vertices[c++] = previous[2];
+
+ const next = path[i + 1] ?? path[i];
+ vertices[c++] = next[0];
+ vertices[c++] = next[1];
+ vertices[c++] = next[2];
+
+ vertices[c++] = direction;
+ vertices[c++] = path_proportion;
+ }
+ path_proportion +=
+ vec3.distance(path[i], path[i + 1] ?? path[i]) / total_distance;
+ }
+
+ return vertices;
+ }
+
+ private createIndex(length: number): Uint32Array {
+ // each line segment is a quad split into two triangles
+ // 0 ----- 2
+ // | / | ^
+ // | / | +direction
+ // point a / point b
+ // | / | -direction
+ // | / | v
+ // 1 ----- 3
+
+ const indices = new Uint32Array((length - 1) * 6);
+ let c = 0;
+
+ for (let i = 0; i < 2 * length; i += 2) {
+ indices[c++] = i + 0;
+ indices[c++] = i + 1;
+ indices[c++] = i + 2;
+
+ indices[c++] = i + 2;
+ indices[c++] = i + 1;
+ indices[c++] = i + 3;
+ }
+ return indices;
+ }
+}
diff --git a/packages/core/src/objects/index.ts b/packages/core/src/objects/index.ts
new file mode 100644
index 00000000..c71e739d
--- /dev/null
+++ b/packages/core/src/objects/index.ts
@@ -0,0 +1,16 @@
+export * from "./cameras/camera";
+export * from "./cameras/controls";
+export * from "./cameras/orthographic_camera";
+export * from "./cameras/perspective_camera";
+
+export * from "./geometry/plane_geometry";
+export * from "./geometry/projected_line_geometry";
+
+export * from "./renderable/image_renderable";
+export * from "./renderable/projected_line";
+
+export * from "./textures/channel";
+export * from "./textures/data_texture_2d";
+export * from "./textures/texture";
+export * from "./textures/texture_2d";
+export * from "./textures/texture_2d_array";
diff --git a/packages/core/src/objects/renderable/image_renderable.ts b/packages/core/src/objects/renderable/image_renderable.ts
new file mode 100644
index 00000000..7614e20b
--- /dev/null
+++ b/packages/core/src/objects/renderable/image_renderable.ts
@@ -0,0 +1,124 @@
+import { RenderableObject } from "src/core/renderable_object";
+import { Geometry } from "src/core/geometry";
+import { Texture } from "src/objects/textures/texture";
+import {
+ Channel,
+ ChannelProps,
+ validateChannel,
+ validateChannels,
+} from "src/objects/textures/channel";
+
+type SingleUniformValues = {
+ Color: [number, number, number];
+ ValueOffset: number;
+ ValueScale: number;
+};
+
+type ArrayUniformValues = {
+ "Visible[0]": boolean[];
+ "Color[0]": number[];
+ "ValueOffset[0]": number[];
+ "ValueScale[0]": number[];
+};
+
+export class ImageRenderable extends RenderableObject {
+ private channels_: Required[];
+
+ constructor(
+ geometry: Geometry | null,
+ texture: Texture | null = null,
+ channels: ChannelProps[] = []
+ ) {
+ super();
+
+ if (geometry) {
+ this.geometry = geometry;
+ }
+
+ if (texture) {
+ this.addTexture(texture);
+ }
+
+ this.channels_ = validateChannels(texture, channels);
+ }
+
+ public get type() {
+ return "ImageRenderable";
+ }
+
+ public addTexture(texture: Texture) {
+ super.addTexture(texture);
+ this.setProgramName();
+ }
+
+ public setChannelProps(channels: ChannelProps[]): void {
+ this.channels_ = validateChannels(this.textures[0], channels);
+ }
+
+ // TODO: validate the properties when setting this way?
+ public setChannelProperty(
+ channelIndex: number,
+ property: K,
+ value: Required[K]
+ ): void {
+ if (channelIndex < 0 || channelIndex >= this.channels_.length) {
+ throw new Error(`Invalid channel index: ${channelIndex}`);
+ }
+ this.channels_[channelIndex][property] = value;
+ }
+
+ public override getUniforms(): SingleUniformValues | ArrayUniformValues {
+ const texture = this.textures[0];
+ if (!texture) {
+ throw new Error("No texture set");
+ }
+
+ if (texture.type === "DataTexture2D") {
+ const { color, contrastLimits } =
+ this.channels_[0] ?? validateChannel(texture, {});
+ return {
+ Color: color,
+ ValueOffset: -contrastLimits[0],
+ ValueScale: 1 / (contrastLimits[1] - contrastLimits[0]),
+ };
+ } else {
+ // Texture2DArray case
+ const visible: boolean[] = [];
+ const color: number[] = [];
+ const valueOffset: number[] = [];
+ const valueScale: number[] = [];
+
+ // All channels (including defaults) are already in this.channels_
+ this.channels_.forEach((channel) => {
+ visible.push(channel.visible);
+ color.push(...channel.color);
+ valueOffset.push(-channel.contrastLimits[0]);
+ valueScale.push(
+ 1 / (channel.contrastLimits[1] - channel.contrastLimits[0])
+ );
+ });
+
+ return {
+ "Visible[0]": visible,
+ "Color[0]": color,
+ "ValueOffset[0]": valueOffset,
+ "ValueScale[0]": valueScale,
+ };
+ }
+ }
+
+ private setProgramName() {
+ const texture = this.textures[0];
+ if (!texture) {
+ throw new Error("un-textured image not implemented");
+ } else if (texture.type == "Texture2D") {
+ this.programName = "mesh";
+ } else if (texture.type == "DataTexture2D") {
+ this.programName =
+ texture.dataType == "float" ? "floatImage" : "uintImage";
+ } else if (texture.type == "Texture2DArray") {
+ this.programName =
+ texture.dataType == "float" ? "floatImageArray" : "uintImageArray";
+ }
+ }
+}
diff --git a/packages/core/src/objects/renderable/projected_line.ts b/packages/core/src/objects/renderable/projected_line.ts
new file mode 100644
index 00000000..0c36f28f
--- /dev/null
+++ b/packages/core/src/objects/renderable/projected_line.ts
@@ -0,0 +1,79 @@
+import { vec3 } from "gl-matrix";
+import { RenderableObject } from "../../core/renderable_object";
+import { ProjectedLineGeometry } from "../../objects/geometry/projected_line_geometry";
+
+type LineParameters = {
+ geometry: ProjectedLineGeometry;
+ color: vec3;
+ width: number;
+ taperOffset?: number;
+ taperPower?: number;
+};
+
+export class ProjectedLine extends RenderableObject {
+ private color_: vec3;
+ private width_: number;
+ private taperOffset_: number = 0.5;
+ private taperPower_: number = 0.0;
+
+ constructor({
+ geometry,
+ color,
+ width,
+ taperOffset,
+ taperPower,
+ }: LineParameters) {
+ super();
+ this.geometry = geometry;
+ this.color_ = color;
+ this.width_ = width;
+ this.taperOffset_ = taperOffset ?? this.taperOffset_;
+ this.taperPower_ = taperPower ?? this.taperPower_;
+ this.programName = "projectedLine";
+ }
+
+ public get type() {
+ return "ProjectedLine";
+ }
+
+ public get color() {
+ return this.color_;
+ }
+
+ public set color(value: vec3) {
+ this.color_ = value;
+ }
+
+ public get width() {
+ return this.width_;
+ }
+
+ public set width(value: number) {
+ this.width_ = value;
+ }
+
+ public get taperOffset() {
+ return this.taperOffset_;
+ }
+
+ public set taperOffset(value: number) {
+ this.taperOffset_ = value;
+ }
+
+ public get taperPower() {
+ return this.taperPower_;
+ }
+
+ public set taperPower(value: number) {
+ this.taperPower_ = value;
+ }
+
+ public override getUniforms() {
+ return {
+ LineColor: this.color,
+ LineWidth: this.width,
+ TaperOffset: this.taperOffset,
+ TaperPower: this.taperPower,
+ };
+ }
+}
diff --git a/packages/core/src/objects/textures/channel.ts b/packages/core/src/objects/textures/channel.ts
new file mode 100644
index 00000000..ad680ae4
--- /dev/null
+++ b/packages/core/src/objects/textures/channel.ts
@@ -0,0 +1,92 @@
+// TODO: move this file out of `textures`
+import { MAX_CHANNELS } from "../../core/constants";
+import { Texture } from "./texture";
+import { Texture2DArray } from "./texture_2d_array";
+
+type RgbColor = [number, number, number];
+
+export type ChannelProps = {
+ visible?: boolean;
+ color?: RgbColor;
+ contrastLimits?: [number, number];
+};
+
+export type Channel = {
+ visible: boolean;
+ color: RgbColor;
+ contrastLimits: [number, number];
+};
+
+export function validateChannel(
+ texture: Texture | null,
+ { visible, color, contrastLimits }: ChannelProps
+): Channel {
+ if (visible === undefined) {
+ visible = true;
+ }
+ if (color === undefined) {
+ color = [1, 1, 1];
+ }
+ if (texture !== null) {
+ contrastLimits = validateContrastLimits(contrastLimits, texture);
+ } else if (contrastLimits === undefined) {
+ console.debug(
+ "No texture provided, defaulting channel contrast limits to [0, 1]."
+ );
+ contrastLimits = [0, 1];
+ }
+ return {
+ visible,
+ color,
+ contrastLimits,
+ };
+}
+
+export function validateChannels(
+ texture: Texture | null,
+ channelProps: ChannelProps[]
+): Channel[] {
+ if (channelProps.length > MAX_CHANNELS) {
+ throw new Error(`Maximum number of channels is ${MAX_CHANNELS}`);
+ }
+
+ if (texture?.type === "Texture2DArray") {
+ const depth = (texture as Texture2DArray).depth;
+ if (channelProps.length !== depth) {
+ throw new Error(
+ `Number of channels (${channelProps.length}) must match depth of texture (${depth}).`
+ );
+ }
+ }
+
+ return channelProps.map((props) => validateChannel(texture, props));
+}
+
+function contrastLimitsFromTexture(texture: Texture): [number, number] {
+ if (texture.dataFormat === "rgb" || texture.dataFormat === "rgba") {
+ return [0, 1];
+ }
+ switch (texture.dataType) {
+ case "unsigned_byte":
+ return [0, 255];
+ case "unsigned_short":
+ return [0, 65535];
+ case "float":
+ return [0, 1];
+ }
+}
+
+function validateContrastLimits(
+ contrastLimits: [number, number] | undefined,
+ texture: Texture
+): [number, number] {
+ if (contrastLimits === undefined) {
+ return contrastLimitsFromTexture(texture);
+ }
+ if (contrastLimits[1] <= contrastLimits[0]) {
+ throw new Error(
+ `Contrast limits must be strictly increasing: ${contrastLimits}.`
+ );
+ }
+ return contrastLimits;
+}
diff --git a/packages/core/src/objects/textures/data_texture_2d.ts b/packages/core/src/objects/textures/data_texture_2d.ts
new file mode 100644
index 00000000..96702f66
--- /dev/null
+++ b/packages/core/src/objects/textures/data_texture_2d.ts
@@ -0,0 +1,38 @@
+import { DataTextureTypedArray, Texture, bufferToDataType } from "./texture";
+
+export class DataTexture2D extends Texture {
+ private data_: DataTextureTypedArray;
+ private readonly width_: number;
+ private readonly height_: number;
+
+ constructor(data: DataTextureTypedArray, width: number, height: number) {
+ super();
+ this.dataFormat = "scalar";
+ this.dataType = bufferToDataType(data);
+
+ this.data_ = data;
+ this.width_ = width;
+ this.height_ = height;
+ }
+
+ public set data(data: DataTextureTypedArray) {
+ this.data_ = data;
+ this.needsUpdate = true;
+ }
+
+ public get type() {
+ return "DataTexture2D";
+ }
+
+ public get data() {
+ return this.data_;
+ }
+
+ public get width() {
+ return this.width_;
+ }
+
+ public get height() {
+ return this.height_;
+ }
+}
diff --git a/packages/core/src/objects/textures/texture.ts b/packages/core/src/objects/textures/texture.ts
new file mode 100644
index 00000000..43dbfa04
--- /dev/null
+++ b/packages/core/src/objects/textures/texture.ts
@@ -0,0 +1,54 @@
+import { Node } from "../../core/node";
+
+export type TextureFilter = "nearest" | "linear";
+
+export type TextureWrapMode = "repeat" | "clamp_to_edge";
+
+export type TextureDataFormat = "scalar" | "rgb" | "rgba";
+
+export type TextureDataType = "unsigned_byte" | "unsigned_short" | "float";
+
+export type TextureUnpackRowAlignment = 1 | 2 | 4 | 8;
+
+export type DataTextureTypedArray = Uint8Array | Uint16Array | Float32Array;
+
+export function isTextureUnpackRowAlignment(
+ value: number
+): value is TextureUnpackRowAlignment {
+ return value === 1 || value === 2 || value === 4 || value === 8;
+}
+
+export function bufferToDataType(
+ buffer: DataTextureTypedArray
+): TextureDataType {
+ if (buffer instanceof Uint8Array) {
+ return "unsigned_byte";
+ } else if (buffer instanceof Uint16Array) {
+ return "unsigned_short";
+ } else if (buffer instanceof Float32Array) {
+ return "float";
+ }
+ throw new Error("Unsupported buffer type.");
+}
+
+export abstract class Texture extends Node {
+ public dataFormat: TextureDataFormat = "rgba";
+ public dataType: TextureDataType = "unsigned_byte";
+ public maxFilter: TextureFilter = "nearest";
+ public minFilter: TextureFilter = "nearest";
+ public mipmapLevels = 1;
+ public unpackAlignment: TextureUnpackRowAlignment = 4;
+ public unpackRowLength = 0;
+ public wrapR: TextureWrapMode = "repeat";
+ public wrapS: TextureWrapMode = "repeat";
+ public wrapT: TextureWrapMode = "repeat";
+ public needsUpdate = true;
+
+ public abstract get width(): number;
+ public abstract get height(): number;
+ public abstract get data(): TexImageSource | ArrayBufferView | null;
+
+ public get type() {
+ return "Texture";
+ }
+}
diff --git a/packages/core/src/objects/textures/texture_2d.ts b/packages/core/src/objects/textures/texture_2d.ts
new file mode 100644
index 00000000..efb138ba
--- /dev/null
+++ b/packages/core/src/objects/textures/texture_2d.ts
@@ -0,0 +1,43 @@
+import { Texture } from "./texture";
+
+export class Texture2D extends Texture {
+ private readonly image_ = new Image();
+ private loaded_ = false;
+
+ constructor(imagePath: string) {
+ super();
+ this.image_.src = imagePath;
+
+ // using bind to ensure that the callbacks have the correct context
+ this.image_.onload = this.onLoad.bind(this);
+ this.image_.onerror = this.onError.bind(this);
+ }
+
+ public get type() {
+ return "Texture2D";
+ }
+
+ private onLoad() {
+ this.loaded_ = true;
+ }
+
+ private onError() {
+ throw new Error("Failed to load texture image");
+ }
+
+ public get width() {
+ return this.image_.width;
+ }
+
+ public get height() {
+ return this.image_.height;
+ }
+
+ public get data() {
+ return this.image_;
+ }
+
+ public get loaded() {
+ return this.loaded_;
+ }
+}
diff --git a/packages/core/src/objects/textures/texture_2d_array.ts b/packages/core/src/objects/textures/texture_2d_array.ts
new file mode 100644
index 00000000..5632c0b9
--- /dev/null
+++ b/packages/core/src/objects/textures/texture_2d_array.ts
@@ -0,0 +1,45 @@
+import { DataTextureTypedArray, Texture, bufferToDataType } from "./texture";
+
+export class Texture2DArray extends Texture {
+ private data_: DataTextureTypedArray;
+ private readonly width_: number;
+ private readonly height_: number;
+ private readonly depth_: number;
+
+ constructor(data: DataTextureTypedArray, width: number, height: number) {
+ super();
+ this.dataFormat = "scalar";
+ this.dataType = bufferToDataType(data);
+
+ this.data_ = data;
+ this.width_ = width;
+ this.height_ = height;
+ // We currently assume that each slice's size is equal to the image's area
+ this.depth_ = data.length / (width * height);
+ }
+
+ public get type() {
+ return "Texture2DArray";
+ }
+
+ public set data(data: DataTextureTypedArray) {
+ this.data_ = data;
+ this.needsUpdate = true;
+ }
+
+ public get data() {
+ return this.data_;
+ }
+
+ public get width() {
+ return this.width_;
+ }
+
+ public get height() {
+ return this.height_;
+ }
+
+ public get depth() {
+ return this.depth_;
+ }
+}
diff --git a/packages/core/src/renderers/index.ts b/packages/core/src/renderers/index.ts
new file mode 100644
index 00000000..c41c7737
--- /dev/null
+++ b/packages/core/src/renderers/index.ts
@@ -0,0 +1,4 @@
+export * from "./webgl_buffers";
+export * from "./webgl_renderer";
+export * from "./webgl_shader_program";
+export * from "./webgl_textures";
diff --git a/packages/core/src/renderers/shaders/float_image_array_frag.glsl b/packages/core/src/renderers/shaders/float_image_array_frag.glsl
new file mode 100644
index 00000000..b847ca0d
--- /dev/null
+++ b/packages/core/src/renderers/shaders/float_image_array_frag.glsl
@@ -0,0 +1,26 @@
+#version 300 es
+
+precision mediump float;
+
+layout (location = 0) out vec4 fragColor;
+
+uniform mediump sampler2DArray texture0;
+// Define a maximum number of channels
+#define MAX_CHANNELS 32
+uniform bool Visible[MAX_CHANNELS];
+uniform vec3 Color[MAX_CHANNELS];
+uniform float ValueOffset[MAX_CHANNELS];
+uniform float ValueScale[MAX_CHANNELS];
+
+in vec2 TexCoords;
+
+void main() {
+ vec3 rgbColor = vec3(0, 0, 0);
+ for (int i = 0; i < MAX_CHANNELS; i++) {
+ if (!Visible[i]) continue;
+ float texel = texture(texture0, vec3(TexCoords, i)).r;
+ float value = (texel + ValueOffset[i]) * ValueScale[i];
+ rgbColor += value * Color[i].rgb;
+ }
+ fragColor = vec4(rgbColor.rgb, 1);
+}
diff --git a/packages/core/src/renderers/shaders/float_image_frag.glsl b/packages/core/src/renderers/shaders/float_image_frag.glsl
new file mode 100644
index 00000000..20510c86
--- /dev/null
+++ b/packages/core/src/renderers/shaders/float_image_frag.glsl
@@ -0,0 +1,18 @@
+#version 300 es
+
+precision mediump float;
+
+layout (location = 0) out vec4 fragColor;
+
+uniform mediump sampler2D texture0;
+uniform vec3 Color;
+uniform float ValueOffset;
+uniform float ValueScale;
+
+in vec2 TexCoords;
+
+void main() {
+ float texel = texture(texture0, TexCoords).r;
+ float value = (texel + ValueOffset) * ValueScale;
+ fragColor = vec4(value * Color.rgb, 1);
+}
\ No newline at end of file
diff --git a/packages/core/src/renderers/shaders/index.ts b/packages/core/src/renderers/shaders/index.ts
new file mode 100644
index 00000000..1226da07
--- /dev/null
+++ b/packages/core/src/renderers/shaders/index.ts
@@ -0,0 +1,46 @@
+import projectedLineVertexShader from "./projected_line_vert.glsl";
+import projectedLineFragmentShader from "./projected_line_frag.glsl";
+import meshVertexShader from "./mesh_vert.glsl";
+import meshFragmentShader from "./mesh_frag.glsl";
+import floatImageFragmentShader from "./float_image_frag.glsl";
+import floatImageArrayFragmentShader from "./float_image_array_frag.glsl";
+import uintImageFragmentShader from "./uint_image_frag.glsl";
+import uintImageArrayFragmentShader from "./uint_image_array_frag.glsl";
+
+export type Shader =
+ | "projectedLine"
+ | "mesh"
+ | "floatImage"
+ | "floatImageArray"
+ | "uintImage"
+ | "uintImageArray";
+
+export const shaderCode: Record =
+ {
+ projectedLine: {
+ vertex: projectedLineVertexShader,
+ fragment: projectedLineFragmentShader,
+ },
+ // TODO: a mesh shader without a texture
+ mesh: {
+ vertex: meshVertexShader,
+ fragment: meshFragmentShader,
+ },
+ // TODO: consolidate image shaders
+ floatImage: {
+ vertex: meshVertexShader,
+ fragment: floatImageFragmentShader,
+ },
+ floatImageArray: {
+ vertex: meshVertexShader,
+ fragment: floatImageArrayFragmentShader,
+ },
+ uintImage: {
+ vertex: meshVertexShader,
+ fragment: uintImageFragmentShader,
+ },
+ uintImageArray: {
+ vertex: meshVertexShader,
+ fragment: uintImageArrayFragmentShader,
+ },
+ };
diff --git a/packages/core/src/renderers/shaders/mesh_frag.glsl b/packages/core/src/renderers/shaders/mesh_frag.glsl
new file mode 100644
index 00000000..3057a50a
--- /dev/null
+++ b/packages/core/src/renderers/shaders/mesh_frag.glsl
@@ -0,0 +1,13 @@
+#version 300 es
+
+precision mediump float;
+
+layout (location = 0) out vec4 fragColor;
+
+uniform sampler2D texture0;
+
+in vec2 TexCoords;
+
+void main() {
+ fragColor = vec4(texture(texture0, TexCoords).rgb, 1.0);
+}
\ No newline at end of file
diff --git a/packages/core/src/renderers/shaders/mesh_vert.glsl b/packages/core/src/renderers/shaders/mesh_vert.glsl
new file mode 100644
index 00000000..bf120377
--- /dev/null
+++ b/packages/core/src/renderers/shaders/mesh_vert.glsl
@@ -0,0 +1,15 @@
+#version 300 es
+
+layout (location = 0) in vec3 inPosition;
+layout (location = 1) in vec3 inNormal;
+layout (location = 2) in vec2 inUV;
+
+uniform mat4 Projection;
+uniform mat4 ModelView;
+
+out vec2 TexCoords;
+
+void main() {
+ TexCoords = inUV;
+ gl_Position = Projection * ModelView * vec4(inPosition, 1.0);
+}
\ No newline at end of file
diff --git a/packages/core/src/renderers/shaders/projected_line_frag.glsl b/packages/core/src/renderers/shaders/projected_line_frag.glsl
new file mode 100644
index 00000000..f4238b29
--- /dev/null
+++ b/packages/core/src/renderers/shaders/projected_line_frag.glsl
@@ -0,0 +1,11 @@
+#version 300 es
+
+precision mediump float;
+
+layout (location = 0) out vec4 fragColor;
+
+uniform vec3 LineColor;
+
+void main() {
+ fragColor = vec4(LineColor, 1.0);
+}
diff --git a/packages/core/src/renderers/shaders/projected_line_vert.glsl b/packages/core/src/renderers/shaders/projected_line_vert.glsl
new file mode 100644
index 00000000..2dfb27e1
--- /dev/null
+++ b/packages/core/src/renderers/shaders/projected_line_vert.glsl
@@ -0,0 +1,68 @@
+#version 300 es
+
+const float PI = 3.14159265;
+
+layout (location = 0) in vec3 inPosition;
+layout (location = 3) in vec3 inPrevPosition;
+layout (location = 4) in vec3 inNextPosition;
+layout (location = 5) in float direction;
+layout (location = 6) in float path_proportion;
+
+uniform mat4 Projection;
+uniform mat4 ModelView;
+uniform vec2 Resolution;
+uniform float LineWidth;
+uniform float TaperOffset;
+uniform float TaperPower;
+
+// adapted from https://github.com/mattdesl/webgl-lines
+void main() {
+ mat4 projModelView = Projection * ModelView;
+
+ vec4 prevPos = projModelView * vec4(inPrevPosition, 1.0);
+ vec4 currPos = projModelView * vec4(inPosition, 1.0);
+ vec4 nextPos = projModelView * vec4(inNextPosition, 1.0);
+
+ vec2 aspectVec = vec2(Resolution.x / Resolution.y, 1.0);
+ vec2 prevScreen = (prevPos.xy / prevPos.w) * aspectVec;
+ vec2 currScreen = (currPos.xy / currPos.w) * aspectVec;
+ vec2 nextScreen = (nextPos.xy / nextPos.w) * aspectVec;
+
+ vec2 diff;
+ if (prevPos == currPos) {
+ // first point on the path
+ diff = nextScreen - currScreen;
+ } else if (nextPos == currPos) {
+ // last point on the path
+ diff = currScreen - prevScreen;
+ } else {
+ // middle point on the path
+ // combine the two directions to get a cheap miter
+ // this is not a true miter join, but it also doesn't explode
+ vec2 prevDiff = currScreen - prevScreen;
+ vec2 nextDiff = nextScreen - currScreen;
+ diff = normalize(prevDiff) + normalize(nextDiff);
+ }
+
+ // direction is + or -; which way to project the vertex away from the path
+ // path_proportion is the distance along the path, from 0 to 1
+ float d = sign(direction);
+ float taper = 1.0;
+ if (TaperPower > 0.0) {
+ // glsl `pow(x, y)` is undefined if x < 0 or x = 0 and y <= 0
+ float t = clamp(path_proportion - TaperOffset, -0.5, 0.5);
+ float angle = PI * t;
+ taper = pow(cos(angle), TaperPower);
+ }
+ vec2 normal = normalize(vec2(-diff.y, diff.x));
+
+ vec4 offset = vec4(
+ normal * d * taper * LineWidth / 2.0 / aspectVec,
+ 0.0,
+ 0.0
+ );
+ gl_Position = currPos + offset * currPos.w;
+
+ // draw as GL_POINTS for debugging
+ gl_PointSize = 5.0;
+}
diff --git a/packages/core/src/renderers/shaders/uint_image_array_frag.glsl b/packages/core/src/renderers/shaders/uint_image_array_frag.glsl
new file mode 100644
index 00000000..6727b020
--- /dev/null
+++ b/packages/core/src/renderers/shaders/uint_image_array_frag.glsl
@@ -0,0 +1,26 @@
+#version 300 es
+
+precision mediump float;
+
+layout (location = 0) out vec4 fragColor;
+
+uniform mediump usampler2DArray texture0;
+// Define a maximum number of channels
+#define MAX_CHANNELS 32
+uniform bool Visible[MAX_CHANNELS];
+uniform vec3 Color[MAX_CHANNELS];
+uniform float ValueOffset[MAX_CHANNELS];
+uniform float ValueScale[MAX_CHANNELS];
+
+in vec2 TexCoords;
+
+void main() {
+ vec3 rgbColor = vec3(0, 0, 0);
+ for (int i = 0; i < MAX_CHANNELS; i++) {
+ if (!Visible[i]) continue;
+ float texel = float(texture(texture0, vec3(TexCoords, i)).r);
+ float value = (texel + ValueOffset[i]) * ValueScale[i];
+ rgbColor += value * Color[i].rgb;
+ }
+ fragColor = vec4(rgbColor.rgb, 1);
+}
diff --git a/packages/core/src/renderers/shaders/uint_image_frag.glsl b/packages/core/src/renderers/shaders/uint_image_frag.glsl
new file mode 100644
index 00000000..759ffe95
--- /dev/null
+++ b/packages/core/src/renderers/shaders/uint_image_frag.glsl
@@ -0,0 +1,18 @@
+#version 300 es
+
+precision mediump float;
+
+layout (location = 0) out vec4 fragColor;
+
+uniform mediump usampler2D texture0;
+uniform vec3 Color;
+uniform float ValueOffset;
+uniform float ValueScale;
+
+in vec2 TexCoords;
+
+void main() {
+ float texel = float(texture(texture0, TexCoords).r);
+ float value = (texel + ValueOffset) * ValueScale;
+ fragColor = vec4(value * Color.rgb, 1);
+}
\ No newline at end of file
diff --git a/packages/core/src/renderers/webgl_buffers.ts b/packages/core/src/renderers/webgl_buffers.ts
new file mode 100644
index 00000000..577ded44
--- /dev/null
+++ b/packages/core/src/renderers/webgl_buffers.ts
@@ -0,0 +1,85 @@
+import { RenderableObject } from "../core/renderable_object";
+
+export class WebGLBuffers {
+ private readonly gl_: WebGL2RenderingContext;
+ private VAOs_: Map = new Map();
+ private currentVAO_: WebGLVertexArrayObject = 0;
+
+ constructor(gl: WebGL2RenderingContext) {
+ this.gl_ = gl;
+ }
+
+ public bind(object: RenderableObject) {
+ const uuid = object.geometry.uuid;
+
+ if (this.alreadyActive(uuid)) return;
+
+ let objectVAO = this.VAOs_.get(uuid) || null;
+ if (!objectVAO) {
+ objectVAO = this.createVAO();
+ }
+
+ this.gl_.bindVertexArray(objectVAO);
+ if (!this.VAOs_.has(uuid)) {
+ this.createBuffers(object);
+ this.VAOs_.set(uuid, objectVAO);
+ }
+
+ this.currentVAO_ = objectVAO!;
+ }
+
+ private alreadyActive(uuid: string) {
+ if (this.currentVAO_ !== 0) {
+ return this.VAOs_.get(uuid) === this.currentVAO_;
+ }
+ return false;
+ }
+
+ private createVAO() {
+ const vao = this.gl_.createVertexArray();
+ if (!vao) {
+ throw new Error(`Unable to generate a vertex array object name`);
+ }
+ return vao;
+ }
+
+ private createBuffers(object: RenderableObject) {
+ const buffer = this.gl_.createBuffer();
+ const { vertexData, indexData, attributes, stride } = object.geometry;
+
+ const bufferType = this.gl_.ARRAY_BUFFER;
+ this.gl_.bindBuffer(bufferType, buffer);
+ this.gl_.bufferData(bufferType, vertexData, this.gl_.STATIC_DRAW);
+
+ attributes.forEach((attr) => {
+ let idx = -1;
+ if (attr.type === "position") idx = 0;
+ if (attr.type === "normal") idx = 1;
+ if (attr.type === "uv") idx = 2;
+ if (attr.type == "previous_position") idx = 3;
+ if (attr.type == "next_position") idx = 4;
+ if (attr.type == "direction") idx = 5;
+ if (attr.type == "path_proportion") idx = 6;
+
+ this.gl_.vertexAttribPointer(
+ idx,
+ attr.itemSize,
+ this.gl_.FLOAT,
+ false,
+ stride,
+ attr.offset
+ );
+ this.gl_.enableVertexAttribArray(idx);
+ });
+
+ if (indexData.length) {
+ const indexBuffer = this.gl_.createBuffer();
+ this.gl_.bindBuffer(this.gl_.ELEMENT_ARRAY_BUFFER, indexBuffer);
+ this.gl_.bufferData(
+ this.gl_.ELEMENT_ARRAY_BUFFER,
+ indexData,
+ this.gl_.STATIC_DRAW
+ );
+ }
+ }
+}
diff --git a/packages/core/src/renderers/webgl_renderer.ts b/packages/core/src/renderers/webgl_renderer.ts
new file mode 100644
index 00000000..0816c7bf
--- /dev/null
+++ b/packages/core/src/renderers/webgl_renderer.ts
@@ -0,0 +1,124 @@
+import { Renderer } from "../core/renderer";
+import { RenderableObject } from "../core/renderable_object";
+import { WebGLShaderProgram } from "./webgl_shader_program";
+
+import { Shader, shaderCode } from "./shaders";
+import { WebGLBuffers } from "./webgl_buffers";
+import { WebGLTextures } from "./webgl_textures";
+
+import { mat4 } from "gl-matrix";
+
+// The library's coordinate system is left-handed.
+// With the default camera, the standard basis vectors should
+// look as follows.
+// (1, 0, 0) points to the right of the screen
+// (0, 1, 0) points to the bottom of the screen
+// (0, 0, 1) points out of the screen
+// WebGL's coordinate system is right-handed where the vectors
+// point in the same directions except that
+// (0, 1, 0) points to the top of the screen
+// Therefore, this transform makes the appropriate flip in y.
+const axisDirection = mat4.fromScaling(mat4.create(), [1, -1, 1]);
+
+export class WebGLRenderer extends Renderer {
+ private readonly gl_: WebGL2RenderingContext | null = null;
+ private readonly shaders_: Map;
+ private readonly bindings_: WebGLBuffers;
+ private readonly textures_: WebGLTextures;
+
+ constructor(selector: string) {
+ super(selector);
+
+ this.gl_ = this.canvas.getContext("webgl2", { depth: true });
+ if (!this.gl_) {
+ throw new Error(`Failed to initialize WebGL2 context`);
+ }
+ console.log(`WebGL version ${this.gl.getParameter(this.gl.VERSION)}`);
+
+ this.shaders_ = new Map();
+ this.bindings_ = new WebGLBuffers(this.gl);
+ this.textures_ = new WebGLTextures(this.gl);
+ this.resize(this.canvas.width, this.canvas.height);
+ }
+
+ protected renderObject(object: RenderableObject) {
+ const program = this.getShaderProgram(object.programName).use();
+
+ const modelView = mat4.multiply(
+ mat4.create(),
+ this.activeCamera.transform.inverse,
+ object.transform.matrix
+ );
+ const projection = mat4.multiply(
+ mat4.create(),
+ axisDirection,
+ this.activeCamera.projectionMatrix
+ );
+ const resolution = [this.canvas.width, this.canvas.height];
+
+ const objectUniforms = object.getUniforms();
+ for (const uniformName of program.uniformNames) {
+ switch (uniformName) {
+ // Set common uniforms with renderer data
+ case "ModelView":
+ program.setUniform(uniformName, modelView);
+ break;
+ case "Projection":
+ program.setUniform(uniformName, projection);
+ break;
+ case "Resolution":
+ program.setUniform(uniformName, resolution);
+ break;
+ default:
+ // Get uniforms from the renderable object
+ if (uniformName in objectUniforms) {
+ program.setUniform(uniformName, objectUniforms[uniformName]);
+ }
+ }
+ }
+
+ this.bindings_.bind(object);
+
+ object.textures.forEach((texture) => {
+ this.textures_.bind(texture);
+ });
+
+ // TODO: Move 'type' property to RenderableObject
+ const type = this.gl.TRIANGLES;
+ const index = object.geometry.indexData;
+ if (index.length) {
+ this.gl.drawElements(type, index.length, this.gl.UNSIGNED_INT, 0);
+ } else {
+ this.gl.drawArrays(type, 0, object.geometry.itemSize);
+ }
+ }
+
+ protected resize(width: number, height: number) {
+ this.gl.viewport(0, 0, width, height);
+ }
+
+ protected clear() {
+ this.gl.clearColor(...this.backgroundColor);
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
+ this.gl.enable(this.gl.DEPTH_TEST);
+ this.gl.depthFunc(this.gl.LEQUAL);
+ }
+
+ private getShaderProgram(type: Shader) {
+ if (!this.shaders_.has(type)) {
+ this.shaders_.set(
+ type,
+ new WebGLShaderProgram(
+ this.gl,
+ shaderCode[type].vertex,
+ shaderCode[type].fragment
+ )
+ );
+ }
+ return this.shaders_.get(type)!;
+ }
+
+ private get gl() {
+ return this.gl_!;
+ }
+}
diff --git a/packages/core/src/renderers/webgl_shader_program.ts b/packages/core/src/renderers/webgl_shader_program.ts
new file mode 100644
index 00000000..27a5d779
--- /dev/null
+++ b/packages/core/src/renderers/webgl_shader_program.ts
@@ -0,0 +1,191 @@
+import { mat4, vec2, vec3 } from "gl-matrix";
+
+type ShaderMap = {
+ [type: number]: {
+ shader: WebGLShader;
+ source: string;
+ };
+};
+
+export class WebGLShaderProgram {
+ private readonly gl_: WebGL2RenderingContext;
+ private readonly program_: WebGLProgram;
+ private uniformInfo_: Map =
+ new Map();
+ private shaders_: ShaderMap = {};
+
+ constructor(
+ gl: WebGL2RenderingContext,
+ vertexShaderSource: string,
+ fragmentShaderSource: string
+ ) {
+ this.gl_ = gl;
+
+ const program = gl.createProgram();
+ if (!program) {
+ throw new Error(`Failed to create WebGL shader program`);
+ }
+ this.program_ = program;
+
+ this.addShader(vertexShaderSource, gl.VERTEX_SHADER);
+ this.addShader(fragmentShaderSource, gl.FRAGMENT_SHADER);
+ this.link();
+ }
+
+ public setUniform(name: string, value: unknown) {
+ const [location, info] = this.uniformInfo_.get(name) ?? [];
+ if (!location || !info) {
+ throw new Error(`Uniform "${name}" not found in shader program`);
+ }
+
+ const type = info.type as SupportedUniformType;
+ switch (type) {
+ // There is no dedicated uniform1b in WebGL, but passing through
+ // as a float or signed integer works, so fallthrough to float
+ // for simplicity.
+ case this.gl_.BOOL:
+ case this.gl_.FLOAT:
+ if (typeof value === "number") {
+ this.gl_.uniform1f(location, value as number);
+ } else {
+ this.gl_.uniform1fv(location, value as Iterable);
+ }
+ break;
+ case this.gl_.FLOAT_VEC2:
+ this.gl_.uniform2fv(location, value as vec2);
+ break;
+ case this.gl_.FLOAT_VEC3:
+ this.gl_.uniform3fv(location, value as vec3);
+ break;
+ case this.gl_.FLOAT_MAT4:
+ this.gl_.uniformMatrix4fv(location, false, value as mat4);
+ break;
+ default: {
+ const exhaustiveCheck: never = type;
+ throw new Error(`Unhandled uniform type: ${exhaustiveCheck}`);
+ }
+ }
+ }
+
+ private preprocessUniformLocations() {
+ const numUniforms = this.gl_.getProgramParameter(
+ this.program_,
+ this.gl_.ACTIVE_UNIFORMS
+ );
+ for (let i = 0; i < numUniforms; i++) {
+ const info = this.gl_.getActiveUniform(this.program_, i);
+ if (info) {
+ if (SAMPLER_TYPES.has(info.type)) {
+ // texture samplers are also uniforms, but they are handled separately
+ continue;
+ }
+
+ if (!SUPPORTED_UNIFORM_TYPES.has(info.type)) {
+ throw new Error(
+ `Unsupported uniform type "${info.type}" (GLenum) found in shader program for uniform "${info.name}"`
+ );
+ }
+
+ const location = this.gl_.getUniformLocation(this.program_, info.name);
+ if (location) {
+ this.uniformInfo_.set(info.name, [location, info]);
+ console.debug("Uniform found:", info.name, info.type, info.size);
+ }
+ }
+ }
+ }
+
+ private addShader(source: string, type: number) {
+ const shader = this.gl_.createShader(type);
+ if (!shader) {
+ throw new Error(`Failed to create a new shader of type ${type}`);
+ }
+
+ this.gl_.shaderSource(shader, source);
+ this.gl_.compileShader(shader);
+ if (!this.gl_.getShaderParameter(shader, this.gl_.COMPILE_STATUS)) {
+ const message = this.gl_.getShaderInfoLog(shader);
+ this.gl_.deleteShader(shader);
+ throw new Error(`Error compiling shader: ${message}`);
+ }
+
+ this.gl_.attachShader(this.program_, shader);
+ this.shaders_[type] = { shader: shader, source };
+ }
+
+ private link() {
+ this.gl_.linkProgram(this.program_);
+ if (!this.getParameter(this.gl_.LINK_STATUS)) {
+ this.deleteShaders();
+ const message = this.gl_.getProgramInfoLog(this.program_);
+ throw new Error(`Error linking program: ${message}`);
+ }
+
+ this.gl_.validateProgram(this.program_);
+ if (!this.getParameter(this.gl_.VALIDATE_STATUS)) {
+ this.deleteShaders();
+ const message = this.gl_.getProgramInfoLog(this.program_);
+ throw new Error(`Error validating program: ${message}`);
+ }
+
+ this.preprocessUniformLocations();
+ this.deleteShaders();
+ }
+
+ public use() {
+ this.gl_.useProgram(this.program_);
+ const error = this.gl_.getError();
+ if (error !== this.gl_.NO_ERROR) {
+ throw new Error(`Error using WebGL program: ${error}`);
+ }
+ return this;
+ }
+
+ private getParameter(parameter: number) {
+ return this.gl_.getProgramParameter(this.program_, parameter);
+ }
+
+ private deleteShaders() {
+ for (const idx in this.shaders_) {
+ this.gl_.deleteShader(this.shaders_[idx].shader);
+ }
+ this.shaders_ = {};
+ }
+
+ public get uniformNames(): string[] {
+ return Array.from(this.uniformInfo_.keys());
+ }
+}
+
+const SAMPLER_TYPES: ReadonlySet = new Set([
+ WebGL2RenderingContext.SAMPLER_2D,
+ WebGL2RenderingContext.SAMPLER_CUBE,
+ WebGL2RenderingContext.SAMPLER_3D,
+ WebGL2RenderingContext.SAMPLER_2D_ARRAY,
+ WebGL2RenderingContext.SAMPLER_2D_SHADOW,
+ WebGL2RenderingContext.SAMPLER_CUBE_SHADOW,
+ WebGL2RenderingContext.SAMPLER_2D_ARRAY_SHADOW,
+ WebGL2RenderingContext.INT_SAMPLER_2D,
+ WebGL2RenderingContext.INT_SAMPLER_3D,
+ WebGL2RenderingContext.INT_SAMPLER_CUBE,
+ WebGL2RenderingContext.INT_SAMPLER_2D_ARRAY,
+ WebGL2RenderingContext.UNSIGNED_INT_SAMPLER_2D,
+ WebGL2RenderingContext.UNSIGNED_INT_SAMPLER_3D,
+ WebGL2RenderingContext.UNSIGNED_INT_SAMPLER_CUBE,
+ WebGL2RenderingContext.UNSIGNED_INT_SAMPLER_2D_ARRAY,
+ WebGL2RenderingContext.MAX_SAMPLES,
+ WebGL2RenderingContext.SAMPLER_BINDING,
+]);
+
+// using an array and converting to a set allows us to also create a type here
+const SUPPORTED_UNIFORM_TYPES_ = [
+ WebGL2RenderingContext.BOOL,
+ WebGL2RenderingContext.FLOAT,
+ WebGL2RenderingContext.FLOAT_VEC2,
+ WebGL2RenderingContext.FLOAT_VEC3,
+ WebGL2RenderingContext.FLOAT_MAT4,
+] as const;
+type SupportedUniformType = (typeof SUPPORTED_UNIFORM_TYPES_)[GLenum];
+const SUPPORTED_UNIFORM_TYPES: ReadonlySet = new Set(
+ SUPPORTED_UNIFORM_TYPES_
+);
diff --git a/packages/core/src/renderers/webgl_textures.ts b/packages/core/src/renderers/webgl_textures.ts
new file mode 100644
index 00000000..eff14be8
--- /dev/null
+++ b/packages/core/src/renderers/webgl_textures.ts
@@ -0,0 +1,267 @@
+import {
+ Texture,
+ TextureFilter,
+ TextureWrapMode,
+ TextureDataType,
+ TextureDataFormat,
+} from "../objects/textures/texture";
+
+import { Texture2D } from "../objects/textures/texture_2d";
+import { DataTexture2D } from "../objects/textures/data_texture_2d";
+import { Texture2DArray } from "../objects/textures/texture_2d_array";
+
+export class WebGLTextures {
+ private readonly gl_: WebGL2RenderingContext;
+ private readonly textures_: Map = new Map();
+ private currentTexture_: WebGLTexture = 0;
+
+ constructor(gl: WebGL2RenderingContext) {
+ this.gl_ = gl;
+ }
+
+ public bind(texture: Texture) {
+ if (this.alreadyActive(texture.uuid) && !texture.needsUpdate) return;
+
+ let textureId = this.textures_.get(texture.uuid) || null;
+ if (!textureId) {
+ textureId = this.createTexture();
+ }
+
+ this.gl_.bindTexture(this.getGLTextureType(texture), textureId);
+ if (!this.textures_.has(texture.uuid)) {
+ this.configureTexture(texture);
+ this.textures_.set(texture.uuid, textureId);
+ }
+
+ if (texture.needsUpdate && texture.data !== null) {
+ // Currently, we don't support mipmaps, so we always update the base level (0).
+ const mipmapLevel = 0;
+
+ // The offsets are always set to zero because we are replacing the entire data set.
+ const offset = { x: 0, y: 0, z: 0 };
+
+ this.uploadTextureSubData(texture, mipmapLevel, offset);
+ texture.needsUpdate = false;
+ }
+
+ this.currentTexture_ = textureId;
+ }
+
+ private alreadyActive(uuid: string) {
+ if (this.currentTexture_ !== 0) {
+ return this.textures_.get(uuid) === this.currentTexture_;
+ }
+ return false;
+ }
+
+ private createTexture() {
+ const texture = this.gl_.createTexture();
+ if (!texture) {
+ throw new Error(`Unable to generate a texture name`);
+ }
+ return texture;
+ }
+
+ private configureTexture(texture: Texture) {
+ this.configureTextureParameters(texture);
+ this.allocateTextureStorage(texture);
+ }
+
+ private configureTextureParameters(texture: Texture) {
+ const gl = this.gl_;
+
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, texture.unpackAlignment);
+ gl.pixelStorei(gl.UNPACK_ROW_LENGTH, texture.unpackRowLength);
+
+ gl.texParameteri(
+ this.getGLTextureType(texture),
+ gl.TEXTURE_MIN_FILTER,
+ this.getGLFilter(texture.minFilter, texture.dataFormat, texture.dataType)
+ );
+
+ gl.texParameteri(
+ this.getGLTextureType(texture),
+ gl.TEXTURE_MAG_FILTER,
+ this.getGLFilter(texture.maxFilter, texture.dataFormat, texture.dataType)
+ );
+
+ gl.texParameteri(
+ this.getGLTextureType(texture),
+ gl.TEXTURE_WRAP_S,
+ this.getGLWrapMode(texture.wrapS)
+ );
+
+ gl.texParameteri(
+ this.getGLTextureType(texture),
+ gl.TEXTURE_WRAP_T,
+ this.getGLWrapMode(texture.wrapT)
+ );
+
+ gl.texParameteri(
+ this.getGLTextureType(texture),
+ gl.TEXTURE_WRAP_R,
+ this.getGLWrapMode(texture.wrapR)
+ );
+ }
+
+ private allocateTextureStorage(texture: Texture) {
+ if (this.isTexture2D(texture) || this.isDataTexture2D(texture)) {
+ this.gl_.texStorage2D(
+ this.getGLTextureType(texture),
+ texture.mipmapLevels,
+ this.getGLInternalFormat(texture.dataFormat, texture.dataType),
+ texture.width,
+ texture.height
+ );
+ } else if (this.isTexture2DArray(texture)) {
+ this.gl_.texStorage3D(
+ this.getGLTextureType(texture),
+ texture.mipmapLevels,
+ this.getGLInternalFormat(texture.dataFormat, texture.dataType),
+ texture.width,
+ texture.height,
+ texture.depth
+ );
+ } else {
+ throw new Error(
+ "Attempting to allocate storage for an unsupported texture type"
+ );
+ }
+ }
+
+ private uploadTextureSubData(
+ texture: Texture,
+ mipmapLevel: number,
+ offset: { x: number; y: number; z: number }
+ ) {
+ if (this.isTexture2D(texture) || this.isDataTexture2D(texture)) {
+ this.gl_.texSubImage2D(
+ this.getGLTextureType(texture),
+ mipmapLevel,
+ offset.x,
+ offset.y,
+ texture.width,
+ texture.height,
+ this.getGLFormat(texture.dataFormat, texture.dataType),
+ this.getGLType(texture.dataType),
+ // This function has multiple overloads. We are temporarily casting it to
+ // ArrayBufferView to ensure the correct overload is called. Once we
+ // consolidate Texture2D and DataTexture2D, we can remove this cast.
+ // https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/texSubImage2D#syntax
+ texture.data as ArrayBufferView
+ );
+ } else if (this.isTexture2DArray(texture)) {
+ this.gl_.texSubImage3D(
+ this.getGLTextureType(texture),
+ mipmapLevel,
+ offset.x,
+ offset.y,
+ offset.z,
+ texture.width,
+ texture.height,
+ texture.depth,
+ this.getGLFormat(texture.dataFormat, texture.dataType),
+ this.getGLType(texture.dataType),
+ texture.data as ArrayBufferView
+ );
+ } else {
+ throw new Error(
+ "Attempting to upload data for an unsupported texture type"
+ );
+ }
+ }
+
+ private getGLTextureType(texture: Texture) {
+ if (this.isTexture2D(texture) || this.isDataTexture2D(texture)) {
+ return this.gl_.TEXTURE_2D;
+ } else if (this.isTexture2DArray(texture)) {
+ return this.gl_.TEXTURE_2D_ARRAY;
+ } else {
+ throw new Error(`Unknown texture type ${texture.type}`);
+ }
+ }
+
+ private getGLFilter(
+ filter: TextureFilter,
+ format: TextureDataFormat,
+ type: TextureDataType
+ ) {
+ if (format == "scalar" && type != "float" && filter != "nearest") {
+ console.warn(
+ "Integer values are not filterable. Using gl.NEAREST instead."
+ );
+ return this.gl_.NEAREST;
+ }
+
+ switch (filter) {
+ case "nearest":
+ return this.gl_.NEAREST;
+ case "linear":
+ return this.gl_.LINEAR;
+ }
+ }
+
+ private getGLWrapMode(mode: TextureWrapMode) {
+ switch (mode) {
+ case "repeat":
+ return this.gl_.REPEAT;
+ case "clamp_to_edge":
+ return this.gl_.CLAMP_TO_EDGE;
+ }
+ }
+
+ private getGLType(type: TextureDataType) {
+ switch (type) {
+ case "unsigned_byte":
+ return this.gl_.UNSIGNED_BYTE;
+ case "unsigned_short":
+ return this.gl_.UNSIGNED_SHORT;
+ case "float":
+ return this.gl_.FLOAT;
+ }
+ }
+
+ private getGLFormat(format: TextureDataFormat, type: TextureDataType) {
+ switch (format) {
+ case "rgb":
+ return this.gl_.RGB;
+ case "rgba":
+ return this.gl_.RGBA;
+ case "scalar":
+ if (type === "float") return this.gl_.RED;
+ return this.gl_.RED_INTEGER;
+ }
+ }
+
+ private getGLInternalFormat(
+ format: TextureDataFormat,
+ type: TextureDataType
+ ) {
+ if (format === "rgba" && type === "unsigned_byte") {
+ return this.gl_.RGBA8;
+ } else if (format === "rgb" && type === "unsigned_byte") {
+ return this.gl_.RGB8;
+ } else if (format === "scalar" && type === "unsigned_byte") {
+ return this.gl_.R8UI;
+ } else if (format === "scalar" && type === "unsigned_short") {
+ return this.gl_.R16UI;
+ } else if (format === "scalar" && type === "float") {
+ return this.gl_.R32F;
+ }
+ throw Error(
+ `Unsupported data format and type combination ${format}/${type}`
+ );
+ }
+
+ private isTexture2D(texture: Texture): texture is Texture2D {
+ return texture.type === "Texture2D";
+ }
+
+ private isDataTexture2D(texture: Texture): texture is DataTexture2D {
+ return texture.type === "DataTexture2D";
+ }
+
+ private isTexture2DArray(texture: Texture): texture is Texture2DArray {
+ return (texture as Texture2DArray).type === "Texture2DArray";
+ }
+}
diff --git a/packages/core/test/layer.test.ts b/packages/core/test/layer.test.ts
new file mode 100644
index 00000000..2dc73e10
--- /dev/null
+++ b/packages/core/test/layer.test.ts
@@ -0,0 +1,36 @@
+import { expect, test, vi } from "vitest";
+
+import { Layer } from "@";
+
+class TestLayer extends Layer {
+ public update() {}
+
+ public setStateReady() {
+ this.setState("ready");
+ }
+}
+
+test("Default layer state is 'initialized'", () => {
+ const layer = new TestLayer();
+
+ expect(layer.state).toBe("initialized");
+});
+
+test("Add state change callback", () => {
+ const layer = new TestLayer();
+ const callback = vi.fn();
+ layer.addStateChangeCallback(callback);
+ layer.setStateReady();
+
+ expect(callback).toHaveBeenCalledWith("ready", "initialized");
+});
+
+test("Remove state change callback", () => {
+ const layer = new TestLayer();
+ const callback = vi.fn();
+ layer.addStateChangeCallback(callback);
+ layer.removeStateChangeCallback(callback);
+ layer.setStateReady();
+
+ expect(callback).toHaveBeenCalledTimes(0);
+});
diff --git a/packages/core/test/projected_line_layer.test.ts b/packages/core/test/projected_line_layer.test.ts
new file mode 100644
index 00000000..b5bb5ae3
--- /dev/null
+++ b/packages/core/test/projected_line_layer.test.ts
@@ -0,0 +1,25 @@
+import { expect, test } from "vitest";
+
+import { ProjectedLineLayer } from "@";
+
+test("construct line layer with 3D lines", () => {
+ const layer = new ProjectedLineLayer([
+ {
+ path: [
+ [0, 0, 0],
+ [1, 1, 1],
+ ],
+ color: [1, 0, 0],
+ width: 1,
+ },
+ {
+ path: [
+ [0, 0, 0],
+ [1, 1, 1],
+ ],
+ color: [1, 0, 0],
+ width: 1,
+ },
+ ]);
+ expect(layer.objects.length).toBe(2);
+});
diff --git a/packages/core/test/promise_scheduler.test.ts b/packages/core/test/promise_scheduler.test.ts
new file mode 100644
index 00000000..7edd8ea1
--- /dev/null
+++ b/packages/core/test/promise_scheduler.test.ts
@@ -0,0 +1,113 @@
+import { AbortError, PromiseScheduler } from "@/data/promise_scheduler";
+import { expect, test } from "vitest";
+
+test("submit one promise", async () => {
+ const executor = new PromiseScheduler(1);
+ let blocked = true;
+ const promise = executor.submit(async () => {
+ while (blocked) {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ return 0;
+ });
+ expect(executor.numPending).toEqual(0);
+ expect(executor.numRunning).toEqual(1);
+ blocked = false;
+ await expect(promise).resolves.toEqual(0);
+ expect(executor.numRunning).toEqual(0);
+ expect(executor.numPending).toEqual(0);
+});
+
+test("submit two concurrent promises", async () => {
+ const executor = new PromiseScheduler(2);
+ let blocked = true;
+ const promise0 = executor.submit(async () => {
+ while (blocked) {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ return 0;
+ });
+ const promise1 = executor.submit(async () => {
+ while (blocked) {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ return 1;
+ });
+ expect(executor.numPending).toEqual(0);
+ expect(executor.numRunning).toEqual(2);
+ blocked = false;
+ await expect(promise0).resolves.toEqual(0);
+ await expect(promise1).resolves.toEqual(1);
+ expect(executor.numRunning).toEqual(0);
+ expect(executor.numPending).toEqual(0);
+});
+
+test("submit one pending promise", async () => {
+ const executor = new PromiseScheduler(1);
+ let blocked = true;
+ const promise0 = executor.submit(async () => {
+ while (blocked) {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ return 0;
+ });
+ const promise1 = executor.submit(async () => {
+ return 1;
+ });
+ expect(executor.numPending).toEqual(1);
+ expect(executor.numRunning).toEqual(1);
+ blocked = false;
+ await expect(promise0).resolves.toEqual(0);
+ await expect(promise1).resolves.toEqual(1);
+ expect(executor.numRunning).toEqual(0);
+ expect(executor.numPending).toEqual(0);
+});
+
+test("cancel one pending promise", async () => {
+ const executor = new PromiseScheduler(1);
+ let blocked = true;
+ const promise0 = executor.submit(async () => {
+ while (blocked) {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ return 0;
+ });
+ const promise1 = executor.submit(async () => {
+ return 1;
+ });
+ expect(executor.numPending).toEqual(1);
+ expect(executor.numRunning).toEqual(1);
+ executor.shutdown();
+ blocked = false;
+ await expect(promise0).resolves.toEqual(0);
+ await expect(promise1).rejects.toThrow(new AbortError("shutdown"));
+ expect(executor.numRunning).toEqual(0);
+ expect(executor.numPending).toEqual(0);
+});
+
+test("submit one promise after shutdown", async () => {
+ const executor = new PromiseScheduler(1);
+ executor.shutdown();
+ await expect(executor.submit(async () => 1)).rejects.toThrow(
+ new AbortError("shutdown")
+ );
+ expect(executor.numPending).toEqual(0);
+ expect(executor.numRunning).toEqual(0);
+});
+
+test("submit one promise that throws", async () => {
+ const executor = new PromiseScheduler(1);
+ let blocked = true;
+ const promise = executor.submit(async () => {
+ while (blocked) {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ throw "test";
+ });
+ expect(executor.numRunning).toEqual(1);
+ expect(executor.numPending).toEqual(0);
+ blocked = false;
+ await expect(promise).rejects.toThrow("test");
+ expect(executor.numRunning).toEqual(0);
+ expect(executor.numPending).toEqual(0);
+});
diff --git a/packages/core/test/transform.test.ts b/packages/core/test/transform.test.ts
new file mode 100644
index 00000000..83a88248
--- /dev/null
+++ b/packages/core/test/transform.test.ts
@@ -0,0 +1,165 @@
+import { mat4, vec3, quat } from "gl-matrix";
+
+import { expect, test, vi } from "vitest";
+
+import { AffineTransform } from "@/core/transforms";
+
+// NOTES:
+// * mat4 is column-major
+// * use mat4.equals instead of expect(t.matrix).toEqual because it's better for comparing floats,
+// even though it provides worse output on failure
+
+const expectMatrixEquals = (a: mat4, b: mat4) => {
+ expect(mat4.equals(a, b)).toBe(true);
+};
+
+test("rotate", () => {
+ const t = new AffineTransform();
+ const q = quat.rotateZ(quat.create(), quat.create(), Math.PI / 2);
+ t.rotate(q);
+ // prettier-ignore
+ expectMatrixEquals(
+ t.matrix,
+ [ 0, 1, 0, 0,
+ -1, 0, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1 ]
+ );
+ t.rotate(quat.invert(quat.create(), q));
+ // prettier-ignore
+ expectMatrixEquals(
+ t.matrix,
+ [ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1 ]
+ );
+});
+
+test("translate", () => {
+ const t = new AffineTransform();
+ const t0 = vec3.fromValues(1, 2, 3);
+ t.translate(t0);
+ // prettier-ignore
+ expectMatrixEquals(
+ t.matrix,
+ [ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 1, 2, 3, 1 ]
+ );
+ t.translate(t0);
+ // prettier-ignore
+ expectMatrixEquals(
+ t.matrix,
+ [ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 2, 4, 6, 1 ]
+ );
+});
+
+test("scale", () => {
+ const t = new AffineTransform();
+ t.scale(vec3.fromValues(2, 3, 4));
+ // prettier-ignore
+ expectMatrixEquals(
+ t.matrix,
+ [ 2, 0, 0, 0,
+ 0, 3, 0, 0,
+ 0, 0, 4, 0,
+ 0, 0, 0, 1 ]
+ );
+ t.scale(vec3.fromValues(2, 3, 4));
+ // prettier-ignore
+ expectMatrixEquals(
+ t.matrix,
+ [ 4, 0, 0, 0,
+ 0, 9, 0, 0,
+ 0, 0, 16, 0,
+ 0, 0, 0, 1 ]
+ );
+});
+
+test("scale then translate", () => {
+ const t = new AffineTransform();
+ t.scale(vec3.fromValues(2, 3, 4));
+ t.translate(vec3.fromValues(1, 2, 3));
+ // prettier-ignore
+ expectMatrixEquals(
+ t.matrix,
+ [ 2, 0, 0, 0,
+ 0, 3, 0, 0,
+ 0, 0, 4, 0,
+ 1, 2, 3, 1 ]
+ );
+});
+
+test("translate then scale", () => {
+ const t = new AffineTransform();
+ t.translate(vec3.fromValues(1, 2, 3));
+ t.scale(vec3.fromValues(2, 3, 4));
+ // prettier-ignore
+ expectMatrixEquals(
+ t.matrix,
+ [ 2, 0, 0, 0,
+ 0, 3, 0, 0,
+ 0, 0, 4, 0,
+ 2, 6, 12, 1 ]
+ );
+});
+
+test("rotate then translate", () => {
+ const t = new AffineTransform();
+ const q = quat.rotateZ(quat.create(), quat.create(), Math.PI / 2);
+ t.rotate(q);
+ t.translate(vec3.fromValues(1, 2, 3));
+ // prettier-ignore
+ expectMatrixEquals(
+ t.matrix,
+ [ 0, 1, 0, 0,
+ -1, 0, 0, 0,
+ 0, 0, 1, 0,
+ 1, 2, 3, 1 ]
+ );
+});
+
+test("translate then rotate", () => {
+ const t = new AffineTransform();
+ t.translate(vec3.fromValues(1, 2, 3));
+ const q = quat.rotateZ(quat.create(), quat.create(), Math.PI / 2);
+ t.rotate(q);
+ // prettier-ignore
+ expectMatrixEquals(
+ t.matrix,
+ [ 0, 1, 0, 0,
+ -1, 0, 0, 0,
+ 0, 0, 1, 0,
+ -2, 1, 3, 1 ]
+ );
+});
+
+test("inverse", () => {
+ // use two transforms to check inverse is correct without first accessing the matrix
+ const t0 = new AffineTransform();
+ const t1 = new AffineTransform();
+ const rotation = quat.rotateZ(quat.create(), quat.create(), Math.PI / 2);
+ const translation = vec3.fromValues(1, 2, 3);
+ const scale = vec3.fromValues(2, 3, 4);
+ t0.rotate(rotation);
+ t0.translate(translation);
+ t0.scale(scale);
+ t1.rotate(rotation);
+ t1.translate(translation);
+ t1.scale(scale);
+ expectMatrixEquals(t0.inverse, mat4.invert(mat4.create(), t1.matrix));
+});
+
+test("matrix is cached on repeat access", () => {
+ const t = new AffineTransform();
+ // @ts-expect-error TS2345 - spying on private method
+ const computeSpy = vi.spyOn(t, "computeMatrix");
+ t.translate(vec3.fromValues(1, 2, 3));
+ expect(t.matrix).toBe(t.matrix);
+ expect(computeSpy).toHaveBeenCalledTimes(1);
+});
diff --git a/packages/core/test/webgl_renderer.test.ts b/packages/core/test/webgl_renderer.test.ts
new file mode 100644
index 00000000..0666b7a2
--- /dev/null
+++ b/packages/core/test/webgl_renderer.test.ts
@@ -0,0 +1,21 @@
+import { expect, test } from "vitest";
+
+import { WebGLRenderer } from "@";
+
+test("Instantiate WebGLRenderer", () => {
+ document.body.innerHTML = '';
+ const renderer = new WebGLRenderer("#canvas");
+ expect(renderer).toBeDefined();
+});
+
+test("Instantiate WebGLRenderer with invalid selector", () => {
+ document.body.innerHTML = "";
+ expect(() => new WebGLRenderer("#canvas")).toThrowError();
+});
+
+test("Instantiate WebGLRenderer with no WebGL context", () => {
+ document.body.innerHTML = '';
+ const canvas = document.querySelector("#canvas")!;
+ canvas.getContext = () => null;
+ expect(() => new WebGLRenderer("#canvas")).toThrowError();
+});
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
new file mode 100644
index 00000000..e6c16ea8
--- /dev/null
+++ b/packages/core/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "extends": "../../tsconfig.json",
+ "target": "ES2020", // Or at least "ES2015"
+ "downlevelIteration": true,
+ "lib": [
+ "ES2020",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "compilerOptions": {
+ "baseUrl": "./",
+ "outDir": "./dist",
+ "composite": true
+ },
+ "include": [
+ "./src/**/*",
+ "jest.setup.ts",
+ "../../.eslintrc.js"
+ ],
+ "exclude": [
+ "node_modules",
+ "**/*.scss",
+ "**/*.css"
+ ],
+ "references": []
+}
diff --git a/tsconfig.json b/tsconfig.json
index 9a132e2b..44e847fc 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -37,10 +37,21 @@
"baseUrl": "./" /* Base directory to resolve non-absolute module names. */,
"paths": {
/* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
- "@czi-sds/components": ["./packages/components/src/*"],
- "@czi-sds/data-viz": ["./packages/data-viz/src/*"],
- "src/*": ["./src/*"],
- "react": ["./node_modules/@types/react"]
+ "@czi-sds/components": [
+ "./packages/components/src/*"
+ ],
+ "@czi-sds/data-viz": [
+ "./packages/data-viz/src/*"
+ ],
+ "@core": [
+ "./packages/core/src/*"
+ ],
+ "src/*": [
+ "./src/*"
+ ],
+ "react": [
+ "./node_modules/@types/react"
+ ]
},
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
@@ -58,7 +69,10 @@
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
"skipLibCheck": true
},
- "include": ["./packages/**/src/**/*", ".eslintrc.js"],
+ "include": [
+ "./packages/**/src/**/*",
+ ".eslintrc.js"
+ ],
"exclude": [
"node_modules",
"./packages/*/node_modules",
diff --git a/yarn.lock b/yarn.lock
index 408f5fa5..d19de880 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -254,6 +254,13 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-string-parser@npm:^7.25.9":
+ version: 7.25.9
+ resolution: "@babel/helper-string-parser@npm:7.25.9"
+ checksum: 10c0/7244b45d8e65f6b4338a6a68a8556f2cb161b782343e97281a5f2b9b93e420cad0d9f5773a59d79f61d0c448913d06f6a2358a87f2e203cf112e3c5b53522ee6
+ languageName: node
+ linkType: hard
+
"@babel/helper-validator-identifier@npm:^7.25.7":
version: 7.25.7
resolution: "@babel/helper-validator-identifier@npm:7.25.7"
@@ -261,6 +268,13 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-validator-identifier@npm:^7.25.9":
+ version: 7.25.9
+ resolution: "@babel/helper-validator-identifier@npm:7.25.9"
+ checksum: 10c0/4fc6f830177b7b7e887ad3277ddb3b91d81e6c4a24151540d9d1023e8dc6b1c0505f0f0628ae653601eb4388a8db45c1c14b2c07a9173837aef7e4116456259d
+ languageName: node
+ linkType: hard
+
"@babel/helper-validator-option@npm:^7.25.7":
version: 7.25.7
resolution: "@babel/helper-validator-option@npm:7.25.7"
@@ -312,6 +326,17 @@ __metadata:
languageName: node
linkType: hard
+"@babel/parser@npm:^7.25.3":
+ version: 7.26.9
+ resolution: "@babel/parser@npm:7.26.9"
+ dependencies:
+ "@babel/types": "npm:^7.26.9"
+ bin:
+ parser: ./bin/babel-parser.js
+ checksum: 10c0/4b9ef3c9a0d4c328e5e5544f50fe8932c36f8a2c851e7f14a85401487cd3da75cad72c2e1bcec1eac55599a6bbb2fdc091f274c4fcafa6bdd112d4915ff087fc
+ languageName: node
+ linkType: hard
+
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.7":
version: 7.25.7
resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.25.7"
@@ -1410,6 +1435,16 @@ __metadata:
languageName: node
linkType: hard
+"@babel/types@npm:^7.26.9":
+ version: 7.26.9
+ resolution: "@babel/types@npm:7.26.9"
+ dependencies:
+ "@babel/helper-string-parser": "npm:^7.25.9"
+ "@babel/helper-validator-identifier": "npm:^7.25.9"
+ checksum: 10c0/999c56269ba00e5c57aa711fbe7ff071cd6990bafd1b978341ea7572cc78919986e2aa6ee51dacf4b6a7a6fa63ba4eb3f1a03cf55eee31b896a56d068b895964
+ languageName: node
+ linkType: hard
+
"@base2/pretty-print-object@npm:1.0.1":
version: 1.0.1
resolution: "@base2/pretty-print-object@npm:1.0.1"
@@ -2304,6 +2339,18 @@ __metadata:
languageName: node
linkType: hard
+"@idetik/core@workspace:packages/core":
+ version: 0.0.0-use.local
+ resolution: "@idetik/core@workspace:packages/core"
+ dependencies:
+ gl-matrix: "npm:^3.4.3"
+ rollup-plugin-glsl: "npm:^1.3.0"
+ vite-plugin-dts: "npm:^4.5.0"
+ zarrita: "npm:^0.4.0-next.16"
+ zod: "npm:^3.24.1"
+ languageName: unknown
+ linkType: soft
+
"@isaacs/cliui@npm:^8.0.2":
version: 8.0.2
resolution: "@isaacs/cliui@npm:8.0.2"
@@ -2766,6 +2813,59 @@ __metadata:
languageName: node
linkType: hard
+"@microsoft/api-extractor-model@npm:7.30.3":
+ version: 7.30.3
+ resolution: "@microsoft/api-extractor-model@npm:7.30.3"
+ dependencies:
+ "@microsoft/tsdoc": "npm:~0.15.1"
+ "@microsoft/tsdoc-config": "npm:~0.17.1"
+ "@rushstack/node-core-library": "npm:5.11.0"
+ checksum: 10c0/2c6f41435bc927470ae90325955d12f5d19a8aa58fab2a5ebe6b7c4eaa5b84288d65b6abec40703f68275a0702b01fdce1850067b0631ca8c0e24a72dfa3b13a
+ languageName: node
+ linkType: hard
+
+"@microsoft/api-extractor@npm:^7.49.1":
+ version: 7.50.1
+ resolution: "@microsoft/api-extractor@npm:7.50.1"
+ dependencies:
+ "@microsoft/api-extractor-model": "npm:7.30.3"
+ "@microsoft/tsdoc": "npm:~0.15.1"
+ "@microsoft/tsdoc-config": "npm:~0.17.1"
+ "@rushstack/node-core-library": "npm:5.11.0"
+ "@rushstack/rig-package": "npm:0.5.3"
+ "@rushstack/terminal": "npm:0.15.0"
+ "@rushstack/ts-command-line": "npm:4.23.5"
+ lodash: "npm:~4.17.15"
+ minimatch: "npm:~3.0.3"
+ resolve: "npm:~1.22.1"
+ semver: "npm:~7.5.4"
+ source-map: "npm:~0.6.1"
+ typescript: "npm:5.7.3"
+ bin:
+ api-extractor: bin/api-extractor
+ checksum: 10c0/b73be3cdd234163f23d86fa8b5072c5a1078fe9befa4afb4ce147e5de79270e72bd4a0293dc03e786cb9755f9dc7f00b99fc5bceea373978b9665528386f8187
+ languageName: node
+ linkType: hard
+
+"@microsoft/tsdoc-config@npm:~0.17.1":
+ version: 0.17.1
+ resolution: "@microsoft/tsdoc-config@npm:0.17.1"
+ dependencies:
+ "@microsoft/tsdoc": "npm:0.15.1"
+ ajv: "npm:~8.12.0"
+ jju: "npm:~1.4.0"
+ resolve: "npm:~1.22.2"
+ checksum: 10c0/a686355796f492f27af17e2a17d615221309caf4d9f9047a5a8f17f8625c467c4c81e2a7923ddafd71b892631d5e5013c4b8cc49c5867d3cc1d260fd90c1413d
+ languageName: node
+ linkType: hard
+
+"@microsoft/tsdoc@npm:0.15.1, @microsoft/tsdoc@npm:~0.15.1":
+ version: 0.15.1
+ resolution: "@microsoft/tsdoc@npm:0.15.1"
+ checksum: 10c0/09948691fac56c45a0d1920de478d66a30371a325bd81addc92eea5654d95106ce173c440fea1a1bd5bb95b3a544b6d4def7bb0b5a846c05d043575d8369a20c
+ languageName: node
+ linkType: hard
+
"@mui/base@npm:5.0.0-beta.40":
version: 5.0.0-beta.40
resolution: "@mui/base@npm:5.0.0-beta.40"
@@ -3886,6 +3986,22 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/pluginutils@npm:^5.1.4":
+ version: 5.1.4
+ resolution: "@rollup/pluginutils@npm:5.1.4"
+ dependencies:
+ "@types/estree": "npm:^1.0.0"
+ estree-walker: "npm:^2.0.2"
+ picomatch: "npm:^4.0.2"
+ peerDependencies:
+ rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+ checksum: 10c0/6d58fbc6f1024eb4b087bc9bf59a1d655a8056a60c0b4021d3beaeec3f0743503f52467fd89d2cf0e7eccf2831feb40a05ad541a17637ea21ba10b21c2004deb
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-android-arm-eabi@npm:4.24.0":
version: 4.24.0
resolution: "@rollup/rollup-android-arm-eabi@npm:4.24.0"
@@ -4005,6 +4121,64 @@ __metadata:
languageName: node
linkType: hard
+"@rushstack/node-core-library@npm:5.11.0":
+ version: 5.11.0
+ resolution: "@rushstack/node-core-library@npm:5.11.0"
+ dependencies:
+ ajv: "npm:~8.13.0"
+ ajv-draft-04: "npm:~1.0.0"
+ ajv-formats: "npm:~3.0.1"
+ fs-extra: "npm:~11.3.0"
+ import-lazy: "npm:~4.0.0"
+ jju: "npm:~1.4.0"
+ resolve: "npm:~1.22.1"
+ semver: "npm:~7.5.4"
+ peerDependencies:
+ "@types/node": "*"
+ peerDependenciesMeta:
+ "@types/node":
+ optional: true
+ checksum: 10c0/7de70fdfa0274ce2fd5e2617c38156143172d852730d03ffb7cfec9ebd6f1bbbc595b81527a189956ee89fe419d9e7d51ffaeaa2d0ee2fc2deae7d24531b7ffb
+ languageName: node
+ linkType: hard
+
+"@rushstack/rig-package@npm:0.5.3":
+ version: 0.5.3
+ resolution: "@rushstack/rig-package@npm:0.5.3"
+ dependencies:
+ resolve: "npm:~1.22.1"
+ strip-json-comments: "npm:~3.1.1"
+ checksum: 10c0/ef0b0115b60007f965b875f671019ac7fc26592f6bf7d7b40fa8c68e8dc37e9f7dcda3b5533b489ebf04d28a182dc60987bfd365a8d4173c73d482b270647741
+ languageName: node
+ linkType: hard
+
+"@rushstack/terminal@npm:0.15.0":
+ version: 0.15.0
+ resolution: "@rushstack/terminal@npm:0.15.0"
+ dependencies:
+ "@rushstack/node-core-library": "npm:5.11.0"
+ supports-color: "npm:~8.1.1"
+ peerDependencies:
+ "@types/node": "*"
+ peerDependenciesMeta:
+ "@types/node":
+ optional: true
+ checksum: 10c0/44e23353e8a4b8024d10d01b9a05fd8d736ddbe2d595a12bfcd290c27842fef156e2471f5e61eed62bad733bd692ba261f1e642c2b1547a0009927805e74e2a6
+ languageName: node
+ linkType: hard
+
+"@rushstack/ts-command-line@npm:4.23.5":
+ version: 4.23.5
+ resolution: "@rushstack/ts-command-line@npm:4.23.5"
+ dependencies:
+ "@rushstack/terminal": "npm:0.15.0"
+ "@types/argparse": "npm:1.0.38"
+ argparse: "npm:~1.0.9"
+ string-argv: "npm:~0.3.1"
+ checksum: 10c0/8c4330620658227bb7af27031d720a826f6a8b92f281cc433393c52968475fddc0031d86477f1676377878130b926b2efb7893edb2d73cdb1fa23444b792e88a
+ languageName: node
+ linkType: hard
+
"@sentry-internal/tracing@npm:7.119.2":
version: 7.119.2
resolution: "@sentry-internal/tracing@npm:7.119.2"
@@ -5268,6 +5442,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/argparse@npm:1.0.38":
+ version: 1.0.38
+ resolution: "@types/argparse@npm:1.0.38"
+ checksum: 10c0/4fc892da5df16923f48180da2d1f4562fa8b0507cf636b24780444fa0a1d7321d4dc0c0ecbee6152968823f5a2ae0d321b4f8c705a489bf1ae1245bdeb0868fd
+ languageName: node
+ linkType: hard
+
"@types/aria-query@npm:^5.0.1":
version: 5.0.4
resolution: "@types/aria-query@npm:5.0.4"
@@ -6186,6 +6367,94 @@ __metadata:
languageName: node
linkType: hard
+"@volar/language-core@npm:2.4.11, @volar/language-core@npm:~2.4.11":
+ version: 2.4.11
+ resolution: "@volar/language-core@npm:2.4.11"
+ dependencies:
+ "@volar/source-map": "npm:2.4.11"
+ checksum: 10c0/ccc5de0c28b4186dc99ff9856b2ac2318ee1818480af3ca406f3c09d42b19b6df8698b525f6cf0fed368332fc76659cd4433fb38e6a55a85c7cefc97d665ccf8
+ languageName: node
+ linkType: hard
+
+"@volar/source-map@npm:2.4.11":
+ version: 2.4.11
+ resolution: "@volar/source-map@npm:2.4.11"
+ checksum: 10c0/8e5badf9f67d669679c48fe32258e082d823523ca2807438d38c0aac6c52b84d43580c8921b559fc5a27c0c7457ffcba569e60de7a597851690dec04ed77c5fc
+ languageName: node
+ linkType: hard
+
+"@volar/typescript@npm:^2.4.11":
+ version: 2.4.11
+ resolution: "@volar/typescript@npm:2.4.11"
+ dependencies:
+ "@volar/language-core": "npm:2.4.11"
+ path-browserify: "npm:^1.0.1"
+ vscode-uri: "npm:^3.0.8"
+ checksum: 10c0/bca9bda9c8c95fd06672b834d1804810fdad496e15ee8e2099f76de74fa529d835f342afb6b976e6e3bc4599a3bbbfd007a842694fe1300cf6286783b827f917
+ languageName: node
+ linkType: hard
+
+"@vue/compiler-core@npm:3.5.13":
+ version: 3.5.13
+ resolution: "@vue/compiler-core@npm:3.5.13"
+ dependencies:
+ "@babel/parser": "npm:^7.25.3"
+ "@vue/shared": "npm:3.5.13"
+ entities: "npm:^4.5.0"
+ estree-walker: "npm:^2.0.2"
+ source-map-js: "npm:^1.2.0"
+ checksum: 10c0/b89f3e3ca92c3177ae449ada1480df13d99b5b3b2cdcf3202fd37dc30f294a1db1f473209f8bae9233e2d338632219d39b2bfa6941d158cea55255e4b0b30f90
+ languageName: node
+ linkType: hard
+
+"@vue/compiler-dom@npm:^3.5.0":
+ version: 3.5.13
+ resolution: "@vue/compiler-dom@npm:3.5.13"
+ dependencies:
+ "@vue/compiler-core": "npm:3.5.13"
+ "@vue/shared": "npm:3.5.13"
+ checksum: 10c0/8f424a71883c9ef4abdd125d2be8d12dd8cf94ba56089245c88734b1f87c65e10597816070ba2ea0a297a2f66dc579f39275a9a53ef5664c143a12409612cd72
+ languageName: node
+ linkType: hard
+
+"@vue/compiler-vue2@npm:^2.7.16":
+ version: 2.7.16
+ resolution: "@vue/compiler-vue2@npm:2.7.16"
+ dependencies:
+ de-indent: "npm:^1.0.2"
+ he: "npm:^1.2.0"
+ checksum: 10c0/c76c3fad770b9a7da40b314116cc9da173da20e5fd68785c8ed8dd8a87d02f239545fa296e16552e040ec86b47bfb18283b39447b250c2e76e479bd6ae475bb3
+ languageName: node
+ linkType: hard
+
+"@vue/language-core@npm:2.2.0":
+ version: 2.2.0
+ resolution: "@vue/language-core@npm:2.2.0"
+ dependencies:
+ "@volar/language-core": "npm:~2.4.11"
+ "@vue/compiler-dom": "npm:^3.5.0"
+ "@vue/compiler-vue2": "npm:^2.7.16"
+ "@vue/shared": "npm:^3.5.0"
+ alien-signals: "npm:^0.4.9"
+ minimatch: "npm:^9.0.3"
+ muggle-string: "npm:^0.4.1"
+ path-browserify: "npm:^1.0.1"
+ peerDependencies:
+ typescript: "*"
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ checksum: 10c0/1c44cc4067266bbc825af358a867aed455963a08c160cd9df9a47571fd917a87d9de9bdea6149877e0c8309a6cf39f263e7cf2fbadeceba47a5a158f392151b2
+ languageName: node
+ linkType: hard
+
+"@vue/shared@npm:3.5.13, @vue/shared@npm:^3.5.0":
+ version: 3.5.13
+ resolution: "@vue/shared@npm:3.5.13"
+ checksum: 10c0/2c940ef907116f1c2583ca1d7733984e5705983ab07054c4e72f1d95eb0f7bdf4d01efbdaee1776c2008f79595963f44e98fced057f5957d86d57b70028f5025
+ languageName: node
+ linkType: hard
+
"@webassemblyjs/ast@npm:1.12.1, @webassemblyjs/ast@npm:^1.12.1":
version: 1.12.1
resolution: "@webassemblyjs/ast@npm:1.12.1"
@@ -6375,6 +6644,36 @@ __metadata:
languageName: node
linkType: hard
+"@zarrita/core@npm:^0.1.0-next.21":
+ version: 0.1.0-next.21
+ resolution: "@zarrita/core@npm:0.1.0-next.21"
+ dependencies:
+ "@zarrita/storage": "npm:^0.1.0-next.9"
+ numcodecs: "npm:^0.3.2"
+ checksum: 10c0/c56679854b8dc8c5c5e500b46acbdf2eb97645024c98a0d72df9341a1458b9814ede38a80e79d49e55ca5f62407051468334363bc091e6f260a71e4576ddef43
+ languageName: node
+ linkType: hard
+
+"@zarrita/indexing@npm:^0.1.0-next.23":
+ version: 0.1.0-next.23
+ resolution: "@zarrita/indexing@npm:0.1.0-next.23"
+ dependencies:
+ "@zarrita/core": "npm:^0.1.0-next.21"
+ "@zarrita/storage": "npm:^0.1.0-next.9"
+ checksum: 10c0/f9f91da138390fbe9820288fbc24a16e64f83ab08a28242bd0cc3f2d1f945ab95e28274c0d6eaaddf42a7bf99af4a250f8d4f2affd6281f0780a5842cf0e0363
+ languageName: node
+ linkType: hard
+
+"@zarrita/storage@npm:^0.1.0-next.9":
+ version: 0.1.0-next.9
+ resolution: "@zarrita/storage@npm:0.1.0-next.9"
+ dependencies:
+ reference-spec-reader: "npm:^0.2.0"
+ unzipit: "npm:^1.4.3"
+ checksum: 10c0/6430b678d15401ef291241a1fedaca25c2873b0ead4a43ac56085a9f41ffc15a12d4ca7fcb8e3ef1bee5cc56010c59ef44bc1767bbe59972dbaf77cffd782df3
+ languageName: node
+ linkType: hard
+
"@zip.js/zip.js@npm:^2.7.44":
version: 2.7.52
resolution: "@zip.js/zip.js@npm:2.7.52"
@@ -6500,6 +6799,15 @@ __metadata:
languageName: node
linkType: hard
+"acorn@npm:^8.14.0":
+ version: 8.14.0
+ resolution: "acorn@npm:8.14.0"
+ bin:
+ acorn: bin/acorn
+ checksum: 10c0/6d4ee461a7734b2f48836ee0fbb752903606e576cc100eb49340295129ca0b452f3ba91ddd4424a1d4406a98adfb2ebb6bd0ff4c49d7a0930c10e462719bbfd7
+ languageName: node
+ linkType: hard
+
"add-stream@npm:^1.0.0":
version: 1.0.0
resolution: "add-stream@npm:1.0.0"
@@ -6535,6 +6843,18 @@ __metadata:
languageName: node
linkType: hard
+"ajv-draft-04@npm:~1.0.0":
+ version: 1.0.0
+ resolution: "ajv-draft-04@npm:1.0.0"
+ peerDependencies:
+ ajv: ^8.5.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+ checksum: 10c0/6044310bd38c17d77549fd326bd40ce1506fa10b0794540aa130180808bf94117fac8c9b448c621512bea60e4a947278f6a978e87f10d342950c15b33ddd9271
+ languageName: node
+ linkType: hard
+
"ajv-formats@npm:^2.1.1":
version: 2.1.1
resolution: "ajv-formats@npm:2.1.1"
@@ -6549,6 +6869,20 @@ __metadata:
languageName: node
linkType: hard
+"ajv-formats@npm:~3.0.1":
+ version: 3.0.1
+ resolution: "ajv-formats@npm:3.0.1"
+ dependencies:
+ ajv: "npm:^8.0.0"
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+ checksum: 10c0/168d6bca1ea9f163b41c8147bae537e67bd963357a5488a1eaf3abe8baa8eec806d4e45f15b10767e6020679315c7e1e5e6803088dfb84efa2b4e9353b83dd0a
+ languageName: node
+ linkType: hard
+
"ajv-keywords@npm:^3.5.2":
version: 3.5.2
resolution: "ajv-keywords@npm:3.5.2"
@@ -6593,6 +6927,37 @@ __metadata:
languageName: node
linkType: hard
+"ajv@npm:~8.12.0":
+ version: 8.12.0
+ resolution: "ajv@npm:8.12.0"
+ dependencies:
+ fast-deep-equal: "npm:^3.1.1"
+ json-schema-traverse: "npm:^1.0.0"
+ require-from-string: "npm:^2.0.2"
+ uri-js: "npm:^4.2.2"
+ checksum: 10c0/ac4f72adf727ee425e049bc9d8b31d4a57e1c90da8d28bcd23d60781b12fcd6fc3d68db5df16994c57b78b94eed7988f5a6b482fd376dc5b084125e20a0a622e
+ languageName: node
+ linkType: hard
+
+"ajv@npm:~8.13.0":
+ version: 8.13.0
+ resolution: "ajv@npm:8.13.0"
+ dependencies:
+ fast-deep-equal: "npm:^3.1.3"
+ json-schema-traverse: "npm:^1.0.0"
+ require-from-string: "npm:^2.0.2"
+ uri-js: "npm:^4.4.1"
+ checksum: 10c0/14c6497b6f72843986d7344175a1aa0e2c35b1e7f7475e55bc582cddb765fca7e6bf950f465dc7846f817776d9541b706f4b5b3fbedd8dfdeb5fce6f22864264
+ languageName: node
+ linkType: hard
+
+"alien-signals@npm:^0.4.9":
+ version: 0.4.14
+ resolution: "alien-signals@npm:0.4.14"
+ checksum: 10c0/5abb3377bcaf6b3819e950084b3ebd022ad90210105afb450c89dc347e80e28da441bf34858a57ea122abe7603e552ddbad80dc597c8f02a0a5206c5fb9c20cb
+ languageName: node
+ linkType: hard
+
"ansi-align@npm:^3.0.0":
version: 3.0.1
resolution: "ansi-align@npm:3.0.1"
@@ -6722,7 +7087,7 @@ __metadata:
languageName: node
linkType: hard
-"argparse@npm:^1.0.7":
+"argparse@npm:^1.0.7, argparse@npm:~1.0.9":
version: 1.0.10
resolution: "argparse@npm:1.0.10"
dependencies:
@@ -8185,6 +8550,13 @@ __metadata:
languageName: node
linkType: hard
+"compare-versions@npm:^6.1.1":
+ version: 6.1.1
+ resolution: "compare-versions@npm:6.1.1"
+ checksum: 10c0/415205c7627f9e4f358f571266422980c9fe2d99086be0c9a48008ef7c771f32b0fbe8e97a441ffedc3910872f917a0675fe0fe3c3b6d331cda6d8690be06338
+ languageName: node
+ linkType: hard
+
"compatfactory@npm:^3.0.0":
version: 3.0.0
resolution: "compatfactory@npm:3.0.0"
@@ -8260,6 +8632,13 @@ __metadata:
languageName: node
linkType: hard
+"confbox@npm:^0.1.8":
+ version: 0.1.8
+ resolution: "confbox@npm:0.1.8"
+ checksum: 10c0/fc2c68d97cb54d885b10b63e45bd8da83a8a71459d3ecf1825143dd4c7f9f1b696b3283e07d9d12a144c1301c2ebc7842380bdf0014e55acc4ae1c9550102418
+ languageName: node
+ linkType: hard
+
"configstore@npm:^5.0.1":
version: 5.0.1
resolution: "configstore@npm:5.0.1"
@@ -8968,6 +9347,18 @@ __metadata:
languageName: node
linkType: hard
+"debug@npm:^4.4.0":
+ version: 4.4.0
+ resolution: "debug@npm:4.4.0"
+ dependencies:
+ ms: "npm:^2.1.3"
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+ checksum: 10c0/db94f1a182bf886f57b4755f85b3a74c39b5114b9377b7ab375dc2cfa3454f09490cc6c30f829df3fc8042bc8b8995f6567ce5cd96f3bc3688bd24027197d9de
+ languageName: node
+ linkType: hard
+
"decamelize-keys@npm:^1.1.0":
version: 1.1.1
resolution: "decamelize-keys@npm:1.1.1"
@@ -10343,6 +10734,13 @@ __metadata:
languageName: node
linkType: hard
+"estree-walker@npm:^0.6.1":
+ version: 0.6.1
+ resolution: "estree-walker@npm:0.6.1"
+ checksum: 10c0/6dabc855faa04a1ffb17b6a9121b6008ba75ab5a163ad9dc3d7fca05cfda374c5f5e91418d783496620ca75e99a73c40874d8b75f23b4117508cc8bde78e7b41
+ languageName: node
+ linkType: hard
+
"estree-walker@npm:^2.0.2":
version: 2.0.2
resolution: "estree-walker@npm:2.0.2"
@@ -10672,6 +11070,13 @@ __metadata:
languageName: node
linkType: hard
+"fflate@npm:^0.8.0":
+ version: 0.8.2
+ resolution: "fflate@npm:0.8.2"
+ checksum: 10c0/03448d630c0a583abea594835a9fdb2aaf7d67787055a761515bf4ed862913cfd693b4c4ffd5c3f3b355a70cf1e19033e9ae5aedcca103188aaff91b8bd6e293
+ languageName: node
+ linkType: hard
+
"figures@npm:3.2.0, figures@npm:^3.0.0":
version: 3.2.0
resolution: "figures@npm:3.2.0"
@@ -11069,6 +11474,17 @@ __metadata:
languageName: node
linkType: hard
+"fs-extra@npm:~11.3.0":
+ version: 11.3.0
+ resolution: "fs-extra@npm:11.3.0"
+ dependencies:
+ graceful-fs: "npm:^4.2.0"
+ jsonfile: "npm:^6.0.1"
+ universalify: "npm:^2.0.0"
+ checksum: 10c0/5f95e996186ff45463059feb115a22fb048bdaf7e487ecee8a8646c78ed8fdca63630e3077d4c16ce677051f5e60d3355a06f3cd61f3ca43f48cc58822a44d0a
+ languageName: node
+ linkType: hard
+
"fs-minipass@npm:^2.0.0":
version: 2.1.0
resolution: "fs-minipass@npm:2.1.0"
@@ -11384,6 +11800,13 @@ __metadata:
languageName: node
linkType: hard
+"gl-matrix@npm:^3.4.3":
+ version: 3.4.3
+ resolution: "gl-matrix@npm:3.4.3"
+ checksum: 10c0/c8ee6e2ce2d089b4ba4ae13ec9d4cb99bf2abe5f68f0cb08d94bbd8bafbec13aacc7230b86539ce5ca01b79226ea8c3194f971f5ca0c81838bc5e4e619dc398e
+ languageName: node
+ linkType: hard
+
"glob-parent@npm:6.0.2, glob-parent@npm:^6.0.2":
version: 6.0.2
resolution: "glob-parent@npm:6.0.2"
@@ -12216,7 +12639,7 @@ __metadata:
languageName: node
linkType: hard
-"import-lazy@npm:^4.0.0":
+"import-lazy@npm:^4.0.0, import-lazy@npm:~4.0.0":
version: 4.0.0
resolution: "import-lazy@npm:4.0.0"
checksum: 10c0/a3520313e2c31f25c0b06aa66d167f329832b68a4f957d7c9daf6e0fa41822b6e84948191648b9b9d8ca82f94740cdf15eecf2401a5b42cd1c33fd84f2225cca
@@ -12549,6 +12972,15 @@ __metadata:
languageName: node
linkType: hard
+"is-core-module@npm:^2.16.0":
+ version: 2.16.1
+ resolution: "is-core-module@npm:2.16.1"
+ dependencies:
+ hasown: "npm:^2.0.2"
+ checksum: 10c0/898443c14780a577e807618aaae2b6f745c8538eca5c7bc11388a3f2dc6de82b9902bcc7eb74f07be672b11bbe82dd6a6edded44a00cb3d8f933d0459905eedd
+ languageName: node
+ linkType: hard
+
"is-data-view@npm:^1.0.1":
version: 1.0.1
resolution: "is-data-view@npm:1.0.1"
@@ -13743,6 +14175,13 @@ __metadata:
languageName: node
linkType: hard
+"jju@npm:~1.4.0":
+ version: 1.4.0
+ resolution: "jju@npm:1.4.0"
+ checksum: 10c0/f3f444557e4364cfc06b1abf8331bf3778b26c0c8552ca54429bc0092652172fdea26cbffe33e1017b303d5aa506f7ede8571857400efe459cb7439180e2acad
+ languageName: node
+ linkType: hard
+
"joi@npm:^17.11.0":
version: 17.13.3
resolution: "joi@npm:17.13.3"
@@ -14091,6 +14530,13 @@ __metadata:
languageName: node
linkType: hard
+"kolorist@npm:^1.8.0":
+ version: 1.8.0
+ resolution: "kolorist@npm:1.8.0"
+ checksum: 10c0/73075db44a692bf6c34a649f3b4b3aea4993b84f6b754cbf7a8577e7c7db44c0bad87752bd23b0ce533f49de2244ce2ce03b7b1b667a85ae170a94782cc50f9b
+ languageName: node
+ linkType: hard
+
"kuler@npm:^2.0.0":
version: 2.0.0
resolution: "kuler@npm:2.0.0"
@@ -14363,6 +14809,16 @@ __metadata:
languageName: node
linkType: hard
+"local-pkg@npm:^0.5.1":
+ version: 0.5.1
+ resolution: "local-pkg@npm:0.5.1"
+ dependencies:
+ mlly: "npm:^1.7.3"
+ pkg-types: "npm:^1.2.1"
+ checksum: 10c0/ade8346f1dc04875921461adee3c40774b00d4b74095261222ebd4d5fd0a444676e36e325f76760f21af6a60bc82480e154909b54d2d9f7173671e36dacf1808
+ languageName: node
+ linkType: hard
+
"localforage@npm:^1.8.1":
version: 1.10.0
resolution: "localforage@npm:1.10.0"
@@ -14470,7 +14926,7 @@ __metadata:
languageName: node
linkType: hard
-"lodash@npm:4.17.21, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21":
+"lodash@npm:4.17.21, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:~4.17.15":
version: 4.17.21
resolution: "lodash@npm:4.17.21"
checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c
@@ -14634,6 +15090,24 @@ __metadata:
languageName: node
linkType: hard
+"magic-string@npm:^0.21.3":
+ version: 0.21.3
+ resolution: "magic-string@npm:0.21.3"
+ dependencies:
+ vlq: "npm:^0.2.1"
+ checksum: 10c0/5e7e03852c64addd8275ab7f351cd3a8da07ad44b13a8e5c0f9c3e87c87a457f5df636bd088ec0176cb005375dbc92f73d6cc2188296885bd125429788b821e1
+ languageName: node
+ linkType: hard
+
+"magic-string@npm:^0.30.17":
+ version: 0.30.17
+ resolution: "magic-string@npm:0.30.17"
+ dependencies:
+ "@jridgewell/sourcemap-codec": "npm:^1.5.0"
+ checksum: 10c0/16826e415d04b88378f200fe022b53e638e3838b9e496edda6c0e086d7753a44a6ed187adc72d19f3623810589bf139af1a315541cd6a26ae0771a0193eaf7b8
+ languageName: node
+ linkType: hard
+
"magic-string@npm:^0.30.2, magic-string@npm:^0.30.3, magic-string@npm:^0.30.5":
version: 0.30.12
resolution: "magic-string@npm:0.30.12"
@@ -15051,7 +15525,7 @@ __metadata:
languageName: node
linkType: hard
-"minimatch@npm:^9.0.0, minimatch@npm:^9.0.4":
+"minimatch@npm:^9.0.0, minimatch@npm:^9.0.3, minimatch@npm:^9.0.4":
version: 9.0.5
resolution: "minimatch@npm:9.0.5"
dependencies:
@@ -15060,6 +15534,15 @@ __metadata:
languageName: node
linkType: hard
+"minimatch@npm:~3.0.3":
+ version: 3.0.8
+ resolution: "minimatch@npm:3.0.8"
+ dependencies:
+ brace-expansion: "npm:^1.1.7"
+ checksum: 10c0/72b226f452dcfb5075255f53534cb83fc25565b909e79b9be4fad463d735cb1084827f7013ff41d050e77ee6e474408c6073473edd2fb72c2fd630cfb0acc6ad
+ languageName: node
+ linkType: hard
+
"minimist-options@npm:4.1.0":
version: 4.1.0
resolution: "minimist-options@npm:4.1.0"
@@ -15196,6 +15679,18 @@ __metadata:
languageName: node
linkType: hard
+"mlly@npm:^1.7.3, mlly@npm:^1.7.4":
+ version: 1.7.4
+ resolution: "mlly@npm:1.7.4"
+ dependencies:
+ acorn: "npm:^8.14.0"
+ pathe: "npm:^2.0.1"
+ pkg-types: "npm:^1.3.0"
+ ufo: "npm:^1.5.4"
+ checksum: 10c0/69e738218a13d6365caf930e0ab4e2b848b84eec261597df9788cefb9930f3e40667be9cb58a4718834ba5f97a6efeef31d3b5a95f4388143fd4e0d0deff72ff
+ languageName: node
+ linkType: hard
+
"mocha@npm:^10.6.0":
version: 10.7.3
resolution: "mocha@npm:10.7.3"
@@ -15248,6 +15743,13 @@ __metadata:
languageName: node
linkType: hard
+"muggle-string@npm:^0.4.1":
+ version: 0.4.1
+ resolution: "muggle-string@npm:0.4.1"
+ checksum: 10c0/e914b63e24cd23f97e18376ec47e4ba3aa24365e4776212b666add2e47bb158003212980d732c49abf3719568900af7861873844a6e2d3a7ca7e86952c0e99e9
+ languageName: node
+ linkType: hard
+
"multimatch@npm:5.0.0":
version: 5.0.0
resolution: "multimatch@npm:5.0.0"
@@ -15612,6 +16114,15 @@ __metadata:
languageName: node
linkType: hard
+"numcodecs@npm:^0.3.2":
+ version: 0.3.2
+ resolution: "numcodecs@npm:0.3.2"
+ dependencies:
+ fflate: "npm:^0.8.0"
+ checksum: 10c0/38e6e60ee063f63f6530f78f9dd4c1b4e347989b29950ddcf3307e43579ad1bc1ba17a6faf0e5cb9786517ac1489c02ee86a77ca8c19fbd70d0d20a23e7d6a81
+ languageName: node
+ linkType: hard
+
"nwsapi@npm:^2.2.2":
version: 2.2.13
resolution: "nwsapi@npm:2.2.13"
@@ -16466,6 +16977,13 @@ __metadata:
languageName: node
linkType: hard
+"pathe@npm:^2.0.1":
+ version: 2.0.3
+ resolution: "pathe@npm:2.0.3"
+ checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1
+ languageName: node
+ linkType: hard
+
"pathval@npm:^2.0.0":
version: 2.0.0
resolution: "pathval@npm:2.0.0"
@@ -16501,6 +17019,13 @@ __metadata:
languageName: node
linkType: hard
+"picomatch@npm:^4.0.2":
+ version: 4.0.2
+ resolution: "picomatch@npm:4.0.2"
+ checksum: 10c0/7c51f3ad2bb42c776f49ebf964c644958158be30d0a510efd5a395e8d49cb5acfed5b82c0c5b365523ce18e6ab85013c9ebe574f60305892ec3fa8eee8304ccc
+ languageName: node
+ linkType: hard
+
"pidtree@npm:~0.6.0":
version: 0.6.0
resolution: "pidtree@npm:0.6.0"
@@ -16554,6 +17079,17 @@ __metadata:
languageName: node
linkType: hard
+"pkg-types@npm:^1.2.1, pkg-types@npm:^1.3.0":
+ version: 1.3.1
+ resolution: "pkg-types@npm:1.3.1"
+ dependencies:
+ confbox: "npm:^0.1.8"
+ mlly: "npm:^1.7.4"
+ pathe: "npm:^2.0.1"
+ checksum: 10c0/19e6cb8b66dcc66c89f2344aecfa47f2431c988cfa3366bdfdcfb1dd6695f87dcce37fbd90fe9d1605e2f4440b77f391e83c23255347c35cf84e7fd774d7fcea
+ languageName: node
+ linkType: hard
+
"playwright-core@npm:1.48.1, playwright-core@npm:>=1.2.0":
version: 1.48.1
resolution: "playwright-core@npm:1.48.1"
@@ -17422,6 +17958,13 @@ __metadata:
languageName: node
linkType: hard
+"reference-spec-reader@npm:^0.2.0":
+ version: 0.2.0
+ resolution: "reference-spec-reader@npm:0.2.0"
+ checksum: 10c0/8d966a0124978edf910aae10fcc56c13c2b66393ce0217819296ff1ff1b626754698c6edc4b2bfe6171acfac827979eb8ef7a884f521794798304be4a523ccd0
+ languageName: node
+ linkType: hard
+
"reflect.getprototypeof@npm:^1.0.4":
version: 1.0.6
resolution: "reflect.getprototypeof@npm:1.0.6"
@@ -17748,6 +18291,19 @@ __metadata:
languageName: node
linkType: hard
+"resolve@npm:~1.22.1, resolve@npm:~1.22.2":
+ version: 1.22.10
+ resolution: "resolve@npm:1.22.10"
+ dependencies:
+ is-core-module: "npm:^2.16.0"
+ path-parse: "npm:^1.0.7"
+ supports-preserve-symlinks-flag: "npm:^1.0.0"
+ bin:
+ resolve: bin/resolve
+ checksum: 10c0/8967e1f4e2cc40f79b7e080b4582b9a8c5ee36ffb46041dccb20e6461161adf69f843b43067b4a375de926a2cd669157e29a29578191def399dd5ef89a1b5203
+ languageName: node
+ linkType: hard
+
"resolve@patch:resolve@npm%3A^1.10.0#optional!builtin, resolve@patch:resolve@npm%3A^1.14.2#optional!builtin, resolve@patch:resolve@npm%3A^1.19.0#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin":
version: 1.22.8
resolution: "resolve@patch:resolve@npm%3A1.22.8#optional!builtin::version=1.22.8&hash=c3c19d"
@@ -17774,6 +18330,19 @@ __metadata:
languageName: node
linkType: hard
+"resolve@patch:resolve@npm%3A~1.22.1#optional!builtin, resolve@patch:resolve@npm%3A~1.22.2#optional!builtin":
+ version: 1.22.10
+ resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin::version=1.22.10&hash=c3c19d"
+ dependencies:
+ is-core-module: "npm:^2.16.0"
+ path-parse: "npm:^1.0.7"
+ supports-preserve-symlinks-flag: "npm:^1.0.0"
+ bin:
+ resolve: bin/resolve
+ checksum: 10c0/52a4e505bbfc7925ac8f4cd91fd8c4e096b6a89728b9f46861d3b405ac9a1ccf4dcbf8befb4e89a2e11370dacd0160918163885cbc669369590f2f31f4c58939
+ languageName: node
+ linkType: hard
+
"responselike@npm:^1.0.2":
version: 1.0.2
resolution: "responselike@npm:1.0.2"
@@ -17902,6 +18471,16 @@ __metadata:
languageName: node
linkType: hard
+"rollup-plugin-glsl@npm:^1.3.0":
+ version: 1.3.0
+ resolution: "rollup-plugin-glsl@npm:1.3.0"
+ dependencies:
+ magic-string: "npm:^0.21.3"
+ rollup-pluginutils: "npm:^2.0.1"
+ checksum: 10c0/1be62910c87ea4c9b16edc8b907eedb51c81f23cabce4d367c27505089968a1dae039c12beb31d95b0ed9ba345f21e918dffed53d17693515787fe39810c864f
+ languageName: node
+ linkType: hard
+
"rollup-plugin-ts@npm:^3.4.5":
version: 3.4.5
resolution: "rollup-plugin-ts@npm:3.4.5"
@@ -17945,6 +18524,15 @@ __metadata:
languageName: node
linkType: hard
+"rollup-pluginutils@npm:^2.0.1":
+ version: 2.8.2
+ resolution: "rollup-pluginutils@npm:2.8.2"
+ dependencies:
+ estree-walker: "npm:^0.6.1"
+ checksum: 10c0/20947bec5a5dd68b5c5c8423911e6e7c0ad834c451f1a929b1f4e2bc08836ad3f1a722ef2bfcbeca921870a0a283f13f064a317dc7a6768496e98c9a641ba290
+ languageName: node
+ linkType: hard
+
"rollup@npm:^0.63.4":
version: 0.63.5
resolution: "rollup@npm:0.63.5"
@@ -18261,6 +18849,17 @@ __metadata:
languageName: node
linkType: hard
+"semver@npm:~7.5.4":
+ version: 7.5.4
+ resolution: "semver@npm:7.5.4"
+ dependencies:
+ lru-cache: "npm:^6.0.0"
+ bin:
+ semver: bin/semver.js
+ checksum: 10c0/5160b06975a38b11c1ab55950cb5b8a23db78df88275d3d8a42ccf1f29e55112ac995b3a26a522c36e3b5f76b0445f1eef70d696b8c7862a2b4303d7b0e7609e
+ languageName: node
+ linkType: hard
+
"send@npm:0.19.0":
version: 0.19.0
resolution: "send@npm:0.19.0"
@@ -18560,7 +19159,7 @@ __metadata:
languageName: node
linkType: hard
-"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.1":
+"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1":
version: 1.2.1
resolution: "source-map-js@npm:1.2.1"
checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf
@@ -18790,7 +19389,7 @@ __metadata:
languageName: node
linkType: hard
-"string-argv@npm:~0.3.2":
+"string-argv@npm:~0.3.1, string-argv@npm:~0.3.2":
version: 0.3.2
resolution: "string-argv@npm:0.3.2"
checksum: 10c0/75c02a83759ad1722e040b86823909d9a2fc75d15dd71ec4b537c3560746e33b5f5a07f7332d1e3f88319909f82190843aa2f0a0d8c8d591ec08e93d5b8dec82
@@ -19023,7 +19622,7 @@ __metadata:
languageName: node
linkType: hard
-"strip-json-comments@npm:3.1.1, strip-json-comments@npm:^3.1.1":
+"strip-json-comments@npm:3.1.1, strip-json-comments@npm:^3.1.1, strip-json-comments@npm:~3.1.1":
version: 3.1.1
resolution: "strip-json-comments@npm:3.1.1"
checksum: 10c0/9681a6257b925a7fa0f285851c0e613cc934a50661fa7bb41ca9cbbff89686bb4a0ee366e6ecedc4daafd01e83eee0720111ab294366fe7c185e935475ebcecd
@@ -19248,7 +19847,7 @@ __metadata:
languageName: node
linkType: hard
-"supports-color@npm:^8.0.0, supports-color@npm:^8.1.1":
+"supports-color@npm:^8.0.0, supports-color@npm:^8.1.1, supports-color@npm:~8.1.1":
version: 8.1.1
resolution: "supports-color@npm:8.1.1"
dependencies:
@@ -19936,6 +20535,16 @@ __metadata:
languageName: node
linkType: hard
+"typescript@npm:5.7.3":
+ version: 5.7.3
+ resolution: "typescript@npm:5.7.3"
+ bin:
+ tsc: bin/tsc
+ tsserver: bin/tsserver
+ checksum: 10c0/b7580d716cf1824736cc6e628ab4cd8b51877408ba2be0869d2866da35ef8366dd6ae9eb9d0851470a39be17cbd61df1126f9e211d8799d764ea7431d5435afa
+ languageName: node
+ linkType: hard
+
"typescript@npm:>=3 < 6":
version: 5.6.3
resolution: "typescript@npm:5.6.3"
@@ -19956,6 +20565,16 @@ __metadata:
languageName: node
linkType: hard
+"typescript@patch:typescript@npm%3A5.7.3#optional!builtin":
+ version: 5.7.3
+ resolution: "typescript@patch:typescript@npm%3A5.7.3#optional!builtin::version=5.7.3&hash=d69c25"
+ bin:
+ tsc: bin/tsc
+ tsserver: bin/tsserver
+ checksum: 10c0/3b56d6afa03d9f6172d0b9cdb10e6b1efc9abc1608efd7a3d2f38773d5d8cfb9bbc68dfb72f0a7de5e8db04fc847f4e4baeddcd5ad9c9feda072234f0d788896
+ languageName: node
+ linkType: hard
+
"typescript@patch:typescript@npm%3A>=3 < 6#optional!builtin":
version: 5.6.3
resolution: "typescript@patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=d69c25"
@@ -19975,6 +20594,13 @@ __metadata:
languageName: node
linkType: hard
+"ufo@npm:^1.5.4":
+ version: 1.5.4
+ resolution: "ufo@npm:1.5.4"
+ checksum: 10c0/b5dc4dc435c49c9ef8890f1b280a19ee4d0954d1d6f9ab66ce62ce64dd04c7be476781531f952a07c678d51638d02ad4b98e16237be29149295b0f7c09cda765
+ languageName: node
+ linkType: hard
+
"uglify-js@npm:^3.1.4":
version: 3.19.3
resolution: "uglify-js@npm:3.19.3"
@@ -20196,6 +20822,15 @@ __metadata:
languageName: node
linkType: hard
+"unzipit@npm:^1.4.3":
+ version: 1.4.3
+ resolution: "unzipit@npm:1.4.3"
+ dependencies:
+ uzip-module: "npm:^1.0.2"
+ checksum: 10c0/6f710615aaafb6283d5f489950e54d158deaa1c8757ba486d98ddc76f3ae3b7eab24cd24017185dd5f4edcd2df43b04431bca009538d4116c60229b60db8dd3b
+ languageName: node
+ linkType: hard
+
"upath@npm:2.0.1, upath@npm:^2.0.1":
version: 2.0.1
resolution: "upath@npm:2.0.1"
@@ -20239,7 +20874,7 @@ __metadata:
languageName: node
linkType: hard
-"uri-js@npm:^4.2.2":
+"uri-js@npm:^4.2.2, uri-js@npm:^4.4.1":
version: 4.4.1
resolution: "uri-js@npm:4.4.1"
dependencies:
@@ -20380,6 +21015,13 @@ __metadata:
languageName: node
linkType: hard
+"uzip-module@npm:^1.0.2":
+ version: 1.0.3
+ resolution: "uzip-module@npm:1.0.3"
+ checksum: 10c0/206e09cf620aa178e5d8ab20425d1a4f4484de9a86673e8a5913087b7b0febb12d49a22b15574a3c5b0c9d911a0d600c9f51651de2a78ffbc6e0345b8ef3af8d
+ languageName: node
+ linkType: hard
+
"v8-compile-cache@npm:^2.2.0":
version: 2.4.0
resolution: "v8-compile-cache@npm:2.4.0"
@@ -20444,6 +21086,43 @@ __metadata:
languageName: node
linkType: hard
+"vite-plugin-dts@npm:^4.5.0":
+ version: 4.5.0
+ resolution: "vite-plugin-dts@npm:4.5.0"
+ dependencies:
+ "@microsoft/api-extractor": "npm:^7.49.1"
+ "@rollup/pluginutils": "npm:^5.1.4"
+ "@volar/typescript": "npm:^2.4.11"
+ "@vue/language-core": "npm:2.2.0"
+ compare-versions: "npm:^6.1.1"
+ debug: "npm:^4.4.0"
+ kolorist: "npm:^1.8.0"
+ local-pkg: "npm:^0.5.1"
+ magic-string: "npm:^0.30.17"
+ peerDependencies:
+ typescript: "*"
+ vite: "*"
+ peerDependenciesMeta:
+ vite:
+ optional: true
+ checksum: 10c0/3ff9eef0d2d8f19b859fdb6494405e4875b270104169c916ac17990393789cd27a74d1e070906175c4c037c54bd1f289177abdf17911a34ef99f82a80f45eebe
+ languageName: node
+ linkType: hard
+
+"vlq@npm:^0.2.1":
+ version: 0.2.3
+ resolution: "vlq@npm:0.2.3"
+ checksum: 10c0/d1557b404353ca75c7affaaf403d245a3273a7d1c6b3380ed7f04ae3f080e4658f41ac700d6f48acb3cd4875fe7bc7da4924b3572cd5584a5de83b35b1de5e12
+ languageName: node
+ linkType: hard
+
+"vscode-uri@npm:^3.0.8":
+ version: 3.1.0
+ resolution: "vscode-uri@npm:3.1.0"
+ checksum: 10c0/5f6c9c10fd9b1664d71fab4e9fbbae6be93c7f75bb3a1d9d74399a88ab8649e99691223fd7cef4644376cac6e94fa2c086d802521b9a8e31c5af3e60f0f35624
+ languageName: node
+ linkType: hard
+
"vue-template-compiler@npm:^2.6.12":
version: 2.7.16
resolution: "vue-template-compiler@npm:2.7.16"
@@ -21143,6 +21822,17 @@ __metadata:
languageName: node
linkType: hard
+"zarrita@npm:^0.4.0-next.16":
+ version: 0.4.0-next.24
+ resolution: "zarrita@npm:0.4.0-next.24"
+ dependencies:
+ "@zarrita/core": "npm:^0.1.0-next.21"
+ "@zarrita/indexing": "npm:^0.1.0-next.23"
+ "@zarrita/storage": "npm:^0.1.0-next.9"
+ checksum: 10c0/b60c591bee969e267b8a6512c93a8bf0fe9b60e4354411be30b185cf6972214475e42b7f3fb51f22408fc25fc7f20b13edd31993e81379d46ce712db3948def7
+ languageName: node
+ linkType: hard
+
"zod@npm:^3.23.8":
version: 3.23.8
resolution: "zod@npm:3.23.8"
@@ -21150,6 +21840,13 @@ __metadata:
languageName: node
linkType: hard
+"zod@npm:^3.24.1":
+ version: 3.24.2
+ resolution: "zod@npm:3.24.2"
+ checksum: 10c0/c638c7220150847f13ad90635b3e7d0321b36cce36f3fc6050ed960689594c949c326dfe2c6fa87c14b126ee5d370ccdebd6efb304f41ef5557a4aaca2824565
+ languageName: node
+ linkType: hard
+
"zrender@npm:5.6.0":
version: 5.6.0
resolution: "zrender@npm:5.6.0"