optimize camera direction

This commit is contained in:
dsehnal
2026-05-27 18:20:25 +02:00
parent 055dfd4946
commit 9be847c74b
5 changed files with 280 additions and 9 deletions

View File

@@ -0,0 +1,141 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { Vec3 } from './vec3';
import { EVD } from '../matrix/evd';
import { Matrix } from '../matrix/matrix';
export interface LeastObstructedDirectionOptions {
/** Optional centroid/origin. If omitted, centroid is computed from the provided points. */
origin?: Vec3,
/** Optional Gaussian falloff distance. If omitted, all points have weight 1. */
sigma?: number,
/** Ignore points closer than this to the origin. */
minDistance?: number,
}
function eachPosition(points: ReadonlyArray<Vec3> | { x: ArrayLike<number>, y: ArrayLike<number>, z: ArrayLike<number> }, callback: (x: number, y: number, z: number) => void) {
if (Array.isArray(points)) {
for (const p of points) {
callback(p[0], p[1], p[2]);
}
} else {
const { x, y, z } = points as { x: ArrayLike<number>, y: ArrayLike<number>, z: ArrayLike<number> };
const n = Math.min(x.length, y.length, z.length);
for (let i = 0; i < n; i++) {
callback(x[i], y[i], z[i]);
}
}
}
/**
* Returns a direction from the selection centroid toward the camera.
*
* Input points should usually be nearby, non-selected atom positions.
*
* The returned direction is a unit Vec3 or undefined if no valid direction could be computed.
*/
export function leastObstructedDirection(
points: ReadonlyArray<Vec3> | { x: ArrayLike<number>, y: ArrayLike<number>, z: ArrayLike<number> },
options: LeastObstructedDirectionOptions = {}
): Vec3 | undefined {
const origin = options.origin;
const minDistance = options.minDistance ?? 1e-6;
const minDistanceSq = minDistance * minDistance;
const sigma = options.sigma;
const useWeights = sigma !== void 0 && sigma > 0;
const twoSigmaSq = useWeights ? 2 * sigma * sigma : 1;
// Directional second moment:
// M = sum_i w_i v_i v_i^T
const evd = EVD.createCache(3);
const M = evd.matrix;
Matrix.makeZero(M);
// Weighted mean direction, used only to choose sign.
const mean = Vec3.zero();
let count = 0;
let weightSum = 0;
eachPosition(points, (x_, y_, z_) => {
let x = x_, y = y_, z = z_;
if (origin) {
x -= origin[0];
y -= origin[1];
z -= origin[2];
}
const dSq = x * x + y * y + z * z;
if (dSq <= minDistanceSq) return;
const d = Math.sqrt(dSq);
const invD = 1 / d;
// Unit obstruction direction v.
x *= invD;
y *= invD;
z *= invD;
const w = useWeights ? Math.exp(-dSq / twoSigmaSq) : 1;
// Accumulate symmetric matrix.
//
// M = [
// xx xy xz
// xy yy yz
// xz yz zz
// ]
Matrix.add(M, 0, 0, w * x * x);
Matrix.add(M, 0, 1, w * x * y);
Matrix.add(M, 0, 2, w * x * z);
Matrix.add(M, 1, 0, w * y * x);
Matrix.add(M, 1, 1, w * y * y);
Matrix.add(M, 1, 2, w * y * z);
Matrix.add(M, 2, 0, w * z * x);
Matrix.add(M, 2, 1, w * z * y);
Matrix.add(M, 2, 2, w * z * z);
mean[0] += w * x;
mean[1] += w * y;
mean[2] += w * z;
count++;
weightSum += w;
});
if (count === 0 || weightSum <= 0) {
return undefined;
}
EVD.compute(evd);
// EVD sorts eigenvalues ascending, so column 0 is the smallest eigenvector.
const dir = Vec3.create(
Matrix.get(M, 0, 0),
Matrix.get(M, 1, 0),
Matrix.get(M, 2, 0)
);
if (Vec3.magnitude(dir) < 1e-6) {
return undefined;
}
Vec3.normalize(dir, dir);
// Pick the less-obstructed side of the axis:
// choose the sign opposite the weighted mean obstruction direction.
if (Vec3.dot(dir, mean) > 0) {
Vec3.scale(dir, dir, -1);
}
return dir;
}

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { Vec3 } from '../3d/vec3';
import { leastObstructedDirection } from '../3d/optimize-direction';
describe('OptimizeDirection', () => {
it('works more or less as expected', () => {
const points: Vec3[] = [
Vec3.create(1, 0, 0),
Vec3.create(-1, 0, 0),
Vec3.create(0, 1, 0),
Vec3.create(0, -1, 0),
Vec3.create(0, 0, 1),
];
const dir = leastObstructedDirection(points);
console.log('dir', dir);
expect(dir).toBeDefined();
expect(dir[0]).toBeCloseTo(0, 6);
expect(dir[1]).toBeCloseTo(0, 6);
expect(dir[2]).toBeCloseTo(-1, 6);
});
});

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2017-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2017-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -572,6 +572,42 @@ export namespace Loci {
return Loci(loci.structure, elements);
}
export function extendToRadius(loci: Loci, radius: number): Loci {
const elementsByUnit = new Map<number, Set<UnitIndex>>();
const lookup = loci.structure.lookup3d;
const pos = Vec3();
forEachLocation(loci, loc => {
loc.unit.conformation.position(loc.element, pos);
const result = lookup.find(pos[0], pos[1], pos[2], radius);
for (let i = 0, il = result.count; i < il; ++i) {
const unit = result.units[i];
const unitIdx = result.indices[i];
let set: Set<UnitIndex> = elementsByUnit.get(unit.id) as Set<UnitIndex>;
if (!set) {
set = new Set();
elementsByUnit.set(unit.id, set);
}
set.add(unitIdx);
}
});
const elements: Element[] = [];
for (const [unitId, indexSet] of elementsByUnit.entries()) {
const unit = loci.structure.unitMap.get(unitId)!;
const indices = Array.from(indexSet) as UnitIndex[];
indices.sort((a, b) => a - b);
elements.push({ unit, indices: makeIndexSet(indices) });
}
return {
kind: 'element-loci',
structure: loci.structure,
elements,
};
}
//
const boundaryHelper = new BoundaryHelper('98');

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2019-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -12,6 +12,7 @@ import { GraphicsRenderObject } from '../../mol-gl/render-object';
import { Sphere3D } from '../../mol-math/geometry';
import { BoundaryHelper } from '../../mol-math/geometry/boundary-helper';
import { Mat3 } from '../../mol-math/linear-algebra';
import { leastObstructedDirection } from '../../mol-math/linear-algebra/3d/optimize-direction';
import { Vec3 } from '../../mol-math/linear-algebra/3d/vec3';
import { PrincipalAxes } from '../../mol-math/linear-algebra/matrix/principal-axes';
import { Loci } from '../../mol-model/loci';
@@ -57,10 +58,59 @@ export class CameraManager {
this.focusSpheres(spheres, s => s, options);
}
focusLoci(loci: Loci | Loci[], options?: Partial<CameraFocusOptions>) {
// TODO: allow computation of principal axes here?
// perhaps have an optimized function, that does exact axes small Loci and approximate/sampled from big ones?
private focusLociOptimized(loci: Loci | Loci[], options?: Partial<CameraFocusOptions & { optimizeRadius?: number, up?: Vec3 }>) {
const { canvas3d } = this.plugin;
if (!canvas3d) return;
const lociArray = Array.isArray(loci) ? loci : [loci];
const spheres: Sphere3D[] = [];
const positions: { x: number[], y: number[], z: number[] } = { x: [], y: [], z: [] };
const t = Vec3();
for (const l of lociArray) {
const s = Loci.getBoundingSphere(this.transformedLoci(l));
if (!s) continue;
spheres.push(s);
if (!StructureElement.Loci.is(l)) continue;
const extended = StructureElement.Loci.extendToRadius(l, options?.optimizeRadius ?? 15);
StructureElement.Loci.forEachLocation(extended, loc => {
loc.unit.conformation.position(loc.element, t);
positions.x.push(t[0]);
positions.y.push(t[1]);
positions.z.push(t[2]);
});
}
if (spheres.length === 0) {
return;
}
this.boundaryHelper.reset();
for (const s of spheres) {
this.boundaryHelper.includeSphere(s);
}
this.boundaryHelper.finishedIncludeStep();
for (const s of spheres) {
this.boundaryHelper.radiusSphere(s);
}
const sphere = this.boundaryHelper.getSphere();
const direction = leastObstructedDirection(positions, { origin: sphere.center, minDistance: 1e-3 });
if (!direction) {
this.focusSphere(sphere, options);
return;
}
Vec3.negate(direction, direction);
const { extraRadius, minRadius, durationMs } = { ...DefaultCameraFocusOptions, ...options };
const radius = Math.max(sphere.radius + extraRadius, minRadius);
const snapshot = canvas3d.camera.getInvariantFocus(sphere.center, radius, options?.up ?? Vec3.unitY, direction);
canvas3d.requestCameraReset({ durationMs, snapshot });
}
private focusLociBase(loci: Loci | Loci[], options?: Partial<CameraFocusOptions>) {
let sphere: Sphere3D | undefined;
if (Array.isArray(loci) && loci.length > 1) {
@@ -93,6 +143,18 @@ export class CameraManager {
}
}
focusLoci(loci: Loci | Loci[], options?: Partial<CameraFocusOptions & { optimizeDirection?: boolean, optimizeDirectionUp?: Vec3, optimizeDirectionRadius?: number }>) {
if (options?.optimizeDirection) {
this.focusLociOptimized(loci, {
...options,
optimizeRadius: options.optimizeDirectionRadius,
up: options.optimizeDirectionUp,
});
} else {
this.focusLociBase(loci, options);
}
}
focusSpheres<T>(xs: ReadonlyArray<T>, sphere: (t: T) => Sphere3D | undefined, options?: Partial<CameraFocusOptions> & { principalAxes?: PrincipalAxes, positionToFlip?: Vec3 }) {
const spheres = [];

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2020-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { OrderedSet, SortedArray } from '../../mol-data/int';
@@ -184,14 +185,18 @@ export class StructureFocusControls extends PluginUIComponent<{}, StructureFocus
} else {
this.plugin.managers.structure.focus.set(f);
}
this.focusCamera();
this.focusCamera(true);
};
toggleAction = () => this.setState({ showAction: !this.state.showAction });
focusCamera = () => {
focusCamera = (optimizeDirection?: boolean) => {
const { current } = this.plugin.managers.structure.focus;
if (current) this.plugin.managers.camera.focusLoci(current.loci);
if (!current) return;
this.plugin.managers.camera.focusLoci(current.loci, {
optimizeDirection,
});
};
clear = () => {