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"