Merge pull request #1822 from corredD/codex/dot-morton-spheres

This commit is contained in:
Alexander Rose
2026-05-10 08:42:22 -07:00
committed by GitHub
3 changed files with 116 additions and 21 deletions

View File

@@ -5,6 +5,7 @@ Note that since we don't clearly distinguish between a public and private interf
## [Unreleased]
- Fix empty transforms default in `ShapeFromPly`
- Use morton order for spheres in dot visual with lod-levels
- Add `Camera.changed` event and rotation/translation setter/getter
- Add `instanceGranularity: 'auto'` as a memory guard
- Honor `instanceGranularity` in `Visual.getLoci`

View File

@@ -0,0 +1,59 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Ludovic Autin <autin@scripps.edu>
*/
import { CustomProperties } from '../../../mol-model/custom-property';
import { Grid, Volume } from '../../../mol-model/volume';
import { Mat4, Tensor } from '../../../mol-math/linear-algebra';
import { createVolumeSphereImpostor } from '../dot';
function createTestVolume(dimensions: [number, number, number], data: number[]): Volume {
return {
grid: {
transform: { kind: 'matrix', matrix: Mat4.identity() },
cells: Tensor.create(Tensor.Space(dimensions, [2, 1, 0]), Tensor.Data1(data)),
stats: { min: 0, max: 1, mean: 0.5, sigma: 0.5 },
} satisfies Grid,
instances: [{ transform: Mat4.identity() }],
sourceData: { kind: 'test', name: 'test', data: {} } as any,
customProperties: new CustomProperties(),
_propertyData: Object.create(null),
_localPropertyData: Object.create(null),
};
}
describe('volume dot representation', () => {
it('adds sphere impostor dots in Morton order for LOD sampling', () => {
const volume = createTestVolume([2, 2, 2], [
1, 1,
1, 1,
1, 1,
1, 1,
]);
const spheres = createVolumeSphereImpostor(undefined as any, volume, 0, undefined as any, {
isoValue: Volume.IsoValue.absolute(0.5),
perturbPositions: false,
lodLevels: [{ minDistance: 0, maxDistance: 0, overlap: 0, stride: 0, scaleBias: 3 }],
} as any);
expect(Array.from(spheres.groupBuffer.ref.value)).toEqual([0, 4, 2, 6, 1, 5, 3, 7]);
});
it('adds sphere impostor dots in row-major order when no LOD levels are configured', () => {
const volume = createTestVolume([2, 2, 2], [
1, 1,
1, 1,
1, 1,
1, 1,
]);
const spheres = createVolumeSphereImpostor(undefined as any, volume, 0, undefined as any, {
isoValue: Volume.IsoValue.absolute(0.5),
perturbPositions: false,
lodLevels: [],
} as any);
expect(Array.from(spheres.groupBuffer.ref.value)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
});
});

View File

@@ -67,7 +67,8 @@ export function VolumeSphereImpostorVisual(materialId: number): VolumeVisual<Vol
setUpdateState: (state: VisualUpdateState, newVolume: Volume, currentVolume: Volume, newProps: PD.Values<VolumeSphereParams>, currentProps: PD.Values<VolumeSphereParams>, newTheme: Theme, currentTheme: Theme) => {
state.createGeometry = (
!Volume.IsoValue.areSame(newProps.isoValue, currentProps.isoValue, newVolume.grid.stats) ||
newProps.perturbPositions !== currentProps.perturbPositions
newProps.perturbPositions !== currentProps.perturbPositions ||
newProps.lodLevels.length > 0 && currentProps.lodLevels.length === 0
);
},
geometryUtils: Spheres.Utils,
@@ -128,32 +129,65 @@ export function createVolumeSphereImpostor(ctx: VisualContext, volume: Volume, k
const p = Vec3();
const [xn, yn, zn] = space.dimensions;
const count = Math.ceil((xn * yn * zn) / 10);
const builder = SpheresBuilder.create(count, Math.ceil(count / 2), spheres);
const invert = isoVal < 0;
// Precompute basis vectors and largest cell axis length
const basis = props.perturbPositions ? getBasis(gridToCartn) : undefined;
for (let z = 0; z < zn; ++z) {
for (let y = 0; y < yn; ++y) {
for (let x = 0; x < xn; ++x) {
const value = space.get(data, x, y, z);
if (!invert && value < isoVal || invert && value > isoVal) continue;
const count = Math.ceil((xn * yn * zn) / 10);
const builder = SpheresBuilder.create(count, Math.ceil(count / 2), spheres);
const cellIdx = space.dataOffset(x, y, z);
if (basis) {
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
const offset = getRandomOffsetFromBasis(basis);
Vec3.add(p, p, offset);
} else {
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
const add = (x: number, y: number, z: number) => {
const value = space.get(data, x, y, z);
if (!invert && value < isoVal || invert && value > isoVal) return;
const cellIdx = space.dataOffset(x, y, z);
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
if (basis) {
Vec3.add(p, p, getRandomOffsetFromBasis(basis));
}
builder.add(p[0], p[1], p[2], cellIdx);
};
// Morton ordering keeps stride-based LOD sampling spatially balanced.
// Only worthwhile when LOD levels are configured; otherwise use the
// direct row-major path to avoid the extra allocations and sort.
const useMortonOrder = props.lodLevels.length > 0;
if (useMortonOrder) {
// Recursive octree traversal over the bounding power-of-two cube,
// visiting children in Morton order (octant bit2=x, bit1=y, bit0=z).
// Octants whose origin already exceeds the grid extent are pruned,
// so out-of-range subtrees of non-cube grids cost ~O(log) per skip.
let size = 1;
while (size < xn || size < yn || size < zn) size <<= 1;
const visit = (x0: number, y0: number, z0: number, s: number): void => {
if (x0 >= xn || y0 >= yn || z0 >= zn) return;
if (s === 1) {
add(x0, y0, z0);
return;
}
const h = s >> 1;
visit(x0, y0, z0, h);
visit(x0, y0, z0 + h, h);
visit(x0, y0 + h, z0, h);
visit(x0, y0 + h, z0 + h, h);
visit(x0 + h, y0, z0, h);
visit(x0 + h, y0, z0 + h, h);
visit(x0 + h, y0 + h, z0, h);
visit(x0 + h, y0 + h, z0 + h, h);
};
visit(0, 0, 0, size);
} else {
for (let z = 0; z < zn; ++z) {
for (let y = 0; y < yn; ++y) {
for (let x = 0; x < xn; ++x) {
add(x, y, z);
}
builder.add(p[0], p[1], p[2], cellIdx);
}
}
}
@@ -320,6 +354,7 @@ const DotVisuals = {
export const DotParams = {
...VolumeSphereParams,
...VolumePointParams,
sizeFactor: PD.Numeric(1, { min: 0, max: 10, step: 0.1 }),
visuals: PD.MultiSelect(['sphere'], PD.objectToOptions(DotVisuals)),
bumpFrequency: PD.Numeric(1, { min: 0, max: 10, step: 0.1 }, BaseGeometry.ShadingCategory),
};
@@ -346,4 +381,4 @@ export const DotRepresentationProvider = VolumeRepresentationProvider({
defaultSizeTheme: { name: 'uniform' },
locationKinds: ['cell-location', 'position-location'],
isApplicable: (volume: Volume) => !Volume.isEmpty(volume) && !Volume.Segmentation.get(volume)
});
});