Browse Source

Merge pull request #270 from molstar/measurements

Additional measurement controls
Alexander Rose 3 years ago
parent
commit
28678e2f80

+ 1 - 0
CHANGELOG.md

@@ -6,6 +6,7 @@ Note that since we don't clearly distinguish between a public and private interf
 
 ## [Unreleased]
 
+- Add additional measurement controls: orientation (box, axes, ellipsoid) & plane (best fit)
 - Improve aromatic bond visuals (add ``aromaticScale``, ``aromaticSpacing``, ``aromaticDashCount`` params)
 - [Breaking] Change ``adjustCylinderLength`` default to ``false`` (set to true for focus representation)
 - Fix marker highlight color overriding select color

+ 9 - 2
src/mol-math/geometry/primitives/axes3d.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -48,7 +48,7 @@ namespace Axes3D {
         return out;
     }
 
-    const tmpTransformMat3 = Mat3.zero();
+    const tmpTransformMat3 = Mat3();
     /** Transform axes with a Mat4 */
     export function transform(out: Axes3D, a: Axes3D, m: Mat4): Axes3D {
         Vec3.transformMat4(out.origin, a.origin, m);
@@ -58,6 +58,13 @@ namespace Axes3D {
         Vec3.transformMat3(out.dirC, a.dirC, n);
         return out;
     }
+
+    export function scale(out: Axes3D, a: Axes3D, scale: number): Axes3D {
+        Vec3.scale(out.dirA, a.dirA, scale);
+        Vec3.scale(out.dirB, a.dirB, scale);
+        Vec3.scale(out.dirC, a.dirC, scale);
+        return out;
+    }
 }
 
 export { Axes3D };

+ 14 - 0
src/mol-model/structure/structure/element/loci.ts

@@ -582,6 +582,20 @@ export namespace Loci {
         return PrincipalAxes.ofPositions(positions);
     }
 
+    export function getPrincipalAxesMany(locis: Loci[]): PrincipalAxes {
+        let elementCount = 0;
+        locis.forEach(l => {
+            elementCount += size(l);
+        });
+        const positions = new Float32Array(3 * elementCount);
+        let offset = 0;
+        locis.forEach(l => {
+            toPositionsArray(l, positions, offset);
+            offset += size(l) * 3;
+        });
+        return PrincipalAxes.ofPositions(positions);
+    }
+
     function sourceIndex(unit: Unit, element: ElementIndex) {
         return Unit.isAtomic(unit)
             ? unit.model.atomicHierarchy.atomSourceIndex.value(element)

+ 48 - 10
src/mol-plugin-state/manager/structure/measurement.ts

@@ -1,7 +1,8 @@
 /**
- * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2021 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>
  */
 
 import { StructureElement } from '../../../mol-model/structure';
@@ -15,6 +16,7 @@ import { StatefulPluginComponent } from '../../component';
 import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { MeasurementRepresentationCommonTextParams, LociLabelTextParams } from '../../../mol-repr/shape/loci/common';
 import { LineParams } from '../../../mol-repr/structure/representation/line';
+import { Expression } from '../../../mol-script/language/expression';
 
 export { StructureMeasurementManager };
 
@@ -35,6 +37,7 @@ export interface StructureMeasurementManagerState {
     angles: StructureMeasurementCell[],
     dihedrals: StructureMeasurementCell[],
     orientations: StructureMeasurementCell[],
+    planes: StructureMeasurementCell[],
     options: StructureMeasurementOptions
 }
 
@@ -222,19 +225,25 @@ class StructureMeasurementManager extends StatefulPluginComponent<StructureMeasu
         await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotLogTiming: true } });
     }
 
-    async addOrientation(a: StructureElement.Loci) {
-        const cellA = this.plugin.helpers.substructureParent.get(a.structure);
+    async addOrientation(locis: StructureElement.Loci[]) {
+        const selections: { key: string, ref: string, groupId?: string, expression: Expression }[] = [];
+        const dependsOn: string[] = [];
 
-        if (!cellA) return;
+        for (let i = 0, il = locis.length; i < il; ++i) {
+            const l = locis[i];
+            const cell = this.plugin.helpers.substructureParent.get(l.structure);
+            if (!cell) continue;
 
-        const dependsOn = [cellA.transform.ref];
+            arraySetAdd(dependsOn, cell.transform.ref);
+            selections.push({ key: `l${i}`, ref: cell.transform.ref, expression: StructureElement.Loci.toExpression(l) });
+        }
+
+        if (selections.length === 0) return;
 
         const update = this.getGroup();
         update
             .apply(StateTransforms.Model.MultiStructureSelectionFromExpression, {
-                selections: [
-                    { key: 'a', ref: cellA.transform.ref, expression: StructureElement.Loci.toExpression(a) },
-                ],
+                selections,
                 isTransitive: true,
                 label: 'Orientation'
             }, { dependsOn })
@@ -244,6 +253,34 @@ class StructureMeasurementManager extends StatefulPluginComponent<StructureMeasu
         await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotLogTiming: true } });
     }
 
+    async addPlane(locis: StructureElement.Loci[]) {
+        const selections: { key: string, ref: string, groupId?: string, expression: Expression }[] = [];
+        const dependsOn: string[] = [];
+
+        for (let i = 0, il = locis.length; i < il; ++i) {
+            const l = locis[i];
+            const cell = this.plugin.helpers.substructureParent.get(l.structure);
+            if (!cell) continue;
+
+            arraySetAdd(dependsOn, cell.transform.ref);
+            selections.push({ key: `l${i}`, ref: cell.transform.ref, expression: StructureElement.Loci.toExpression(l) });
+        }
+
+        if (selections.length === 0) return;
+
+        const update = this.getGroup();
+        update
+            .apply(StateTransforms.Model.MultiStructureSelectionFromExpression, {
+                selections,
+                isTransitive: true,
+                label: 'Plane'
+            }, { dependsOn })
+            .apply(StateTransforms.Representation.StructureSelectionsPlane3D);
+
+        const state = this.plugin.state.data;
+        await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotLogTiming: true } });
+    }
+
     private _empty: any[] = [];
     private getTransforms<T extends StateTransformer<A, B, any>, A extends PluginStateObject.Molecule.Structure.Selections, B extends StateObject>(transformer: T) {
         const state = this.plugin.state.data;
@@ -259,13 +296,14 @@ class StructureMeasurementManager extends StatefulPluginComponent<StructureMeasu
             distances: this.getTransforms(StateTransforms.Representation.StructureSelectionsDistance3D),
             angles: this.getTransforms(StateTransforms.Representation.StructureSelectionsAngle3D),
             dihedrals: this.getTransforms(StateTransforms.Representation.StructureSelectionsDihedral3D),
-            orientations: this.getTransforms(StateTransforms.Representation.StructureSelectionsOrientation3D)
+            orientations: this.getTransforms(StateTransforms.Representation.StructureSelectionsOrientation3D),
+            planes: this.getTransforms(StateTransforms.Representation.StructureSelectionsPlane3D),
         });
         if (updated) this.stateUpdated();
     }
 
     constructor(private plugin: PluginContext) {
-        super({ labels: [], distances: [], angles: [], dihedrals: [], orientations: [], options: DefaultStructureMeasurementOptions });
+        super({ labels: [], distances: [], angles: [], dihedrals: [], orientations: [], planes: [], options: DefaultStructureMeasurementOptions });
 
         plugin.state.data.events.changed.subscribe(e => {
             if (e.inTransaction || plugin.behaviors.state.isAnimating.value) return;

+ 4 - 9
src/mol-plugin-state/manager/structure/selection.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2021 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>
@@ -22,6 +22,7 @@ import { PluginStateObject as PSO } from '../../objects';
 import { UUID } from '../../../mol-util';
 import { StructureRef } from './hierarchy-state';
 import { Boundary } from '../../../mol-math/geometry/boundary';
+import { iterableToArray } from '../../../mol-data/util';
 
 interface StructureSelectionManagerState {
     entries: Map<string, SelectionEntry>,
@@ -405,14 +406,8 @@ export class StructureSelectionManager extends StatefulPluginComponent<Structure
     }
 
     getPrincipalAxes(): PrincipalAxes {
-        const elementCount = this.elementCount();
-        const positions = new Float32Array(3 * elementCount);
-        let offset = 0;
-        this.entries.forEach(v => {
-            StructureElement.Loci.toPositionsArray(v.selection, positions, offset);
-            offset += StructureElement.Loci.size(v.selection) * 3;
-        });
-        return PrincipalAxes.ofPositions(positions);
+        const values = iterableToArray(this.entries.values());
+        return StructureElement.Loci.getPrincipalAxesMany(values.map(v => v.selection));
     }
 
     modify(modifier: StructureSelectionModifier, loci: Loci) {

+ 7 - 3
src/mol-plugin-state/transforms/helpers.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -10,6 +10,7 @@ import { LabelData } from '../../mol-repr/shape/loci/label';
 import { OrientationData } from '../../mol-repr/shape/loci/orientation';
 import { AngleData } from '../../mol-repr/shape/loci/angle';
 import { DihedralData } from '../../mol-repr/shape/loci/dihedral';
+import { PlaneData } from '../../mol-repr/shape/loci/plane';
 
 export function getDistanceDataFromStructureSelections(s: ReadonlyArray<PluginStateObject.Molecule.Structure.SelectionEntry>): DistanceData {
     const lociA = s[0].loci;
@@ -38,6 +39,9 @@ export function getLabelDataFromStructureSelections(s: ReadonlyArray<PluginState
 }
 
 export function getOrientationDataFromStructureSelections(s: ReadonlyArray<PluginStateObject.Molecule.Structure.SelectionEntry>): OrientationData {
-    const loci = s[0].loci;
-    return { locis: [loci] };
+    return { locis: s.map(v => v.loci) };
+}
+
+export function getPlaneDataFromStructureSelections(s: ReadonlyArray<PluginStateObject.Molecule.Structure.SelectionEntry>): PlaneData {
+    return { locis: s.map(v => v.loci) };
 }

+ 2 - 1
src/mol-plugin-state/transforms/model.ts

@@ -645,7 +645,8 @@ const MultiStructureSelectionFromExpression = PluginStateTransform.BuiltIn({
                     totalSize += StructureElement.Loci.size(loci.loci);
 
                     continue;
-                } if (entry.expression !== sel.expression) {
+                }
+                if (entry.expression !== sel.expression) {
                     recreate = true;
                 } else {
                     // TODO: properly support "transitive" queries. For that Structure.areUnitAndIndicesEqual needs to be fixed;

+ 36 - 2
src/mol-plugin-state/transforms/representation.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2021 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>
@@ -28,7 +28,7 @@ import { BaseGeometry } from '../../mol-geo/geometry/base';
 import { Script } from '../../mol-script/script';
 import { UnitcellParams, UnitcellRepresentation, getUnitcellData } from '../../mol-repr/shape/model/unitcell';
 import { DistanceParams, DistanceRepresentation } from '../../mol-repr/shape/loci/distance';
-import { getDistanceDataFromStructureSelections, getLabelDataFromStructureSelections, getOrientationDataFromStructureSelections, getAngleDataFromStructureSelections, getDihedralDataFromStructureSelections } from './helpers';
+import { getDistanceDataFromStructureSelections, getLabelDataFromStructureSelections, getOrientationDataFromStructureSelections, getAngleDataFromStructureSelections, getDihedralDataFromStructureSelections, getPlaneDataFromStructureSelections } from './helpers';
 import { LabelParams, LabelRepresentation } from '../../mol-repr/shape/loci/label';
 import { OrientationRepresentation, OrientationParams } from '../../mol-repr/shape/loci/orientation';
 import { AngleParams, AngleRepresentation } from '../../mol-repr/shape/loci/angle';
@@ -40,6 +40,7 @@ import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
 import { getBoxMesh } from './shape';
 import { Shape } from '../../mol-model/shape';
 import { Box3D } from '../../mol-math/geometry';
+import { PlaneParams, PlaneRepresentation } from '../../mol-repr/shape/loci/plane';
 
 export { StructureRepresentation3D };
 export { ExplodeStructureRepresentation3D };
@@ -986,4 +987,37 @@ const StructureSelectionsOrientation3D = PluginStateTransform.BuiltIn({
             return StateTransformer.UpdateResult.Updated;
         });
     },
+});
+
+export { StructureSelectionsPlane3D };
+type StructureSelectionsPlane3D = typeof StructureSelectionsPlane3D
+const StructureSelectionsPlane3D = PluginStateTransform.BuiltIn({
+    name: 'structure-selections-plane-3d',
+    display: '3D Plane',
+    from: SO.Molecule.Structure.Selections,
+    to: SO.Shape.Representation3D,
+    params: () => ({
+        ...PlaneParams,
+    })
+})({
+    canAutoUpdate({ oldParams, newParams }) {
+        return true;
+    },
+    apply({ a, params }, plugin: PluginContext) {
+        return Task.create('Structure Plane', async ctx => {
+            const data = getPlaneDataFromStructureSelections(a.data);
+            const repr = PlaneRepresentation({ webgl: plugin.canvas3d?.webgl, ...plugin.representation.structure.themes }, () => PlaneParams);
+            await repr.createOrUpdate(params, data).runInContext(ctx);
+            return new SO.Shape.Representation3D({ repr, sourceData: data }, { label: `Plane` });
+        });
+    },
+    update({ a, b, oldParams, newParams }, plugin: PluginContext) {
+        return Task.create('Structure Plane', async ctx => {
+            const props = { ...b.data.repr.props, ...newParams };
+            const data = getPlaneDataFromStructureSelections(a.data);
+            await b.data.repr.createOrUpdate(props, data).runInContext(ctx);
+            b.data.sourceData = data;
+            return StateTransformer.UpdateResult.Updated;
+        });
+    },
 });

+ 30 - 7
src/mol-plugin-ui/structure/measurements.tsx

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2021 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>
@@ -15,7 +15,8 @@ import { AngleData } from '../../mol-repr/shape/loci/angle';
 import { DihedralData } from '../../mol-repr/shape/loci/dihedral';
 import { DistanceData } from '../../mol-repr/shape/loci/distance';
 import { LabelData } from '../../mol-repr/shape/loci/label';
-import { angleLabel, dihedralLabel, distanceLabel, lociLabel } from '../../mol-theme/label';
+import { OrientationData } from '../../mol-repr/shape/loci/orientation';
+import { angleLabel, dihedralLabel, distanceLabel, lociLabel, structureElementLociLabelMany } from '../../mol-theme/label';
 import { FiniteArray } from '../../mol-util/type-helpers';
 import { CollapsableControls, PurePluginUIComponent } from '../base';
 import { ActionMenu } from '../controls/action-menu';
@@ -67,6 +68,8 @@ export class MeasurementList extends PurePluginUIComponent {
             {this.renderGroup(measurements.distances, 'Distances')}
             {this.renderGroup(measurements.angles, 'Angles')}
             {this.renderGroup(measurements.dihedrals, 'Dihedrals')}
+            {this.renderGroup(measurements.orientations, 'Orientations')}
+            {this.renderGroup(measurements.planes, 'Planes')}
         </div>;
     }
 }
@@ -108,13 +111,31 @@ export class MeasurementControls extends PurePluginUIComponent<{}, { isBusy: boo
         this.plugin.managers.structure.measurement.addLabel(loci[0].loci);
     }
 
+    addOrientation = () => {
+        const locis: StructureElement.Loci[] = [];
+        this.plugin.managers.structure.selection.entries.forEach(v => {
+            locis.push(v.selection);
+        });
+        this.plugin.managers.structure.measurement.addOrientation(locis);
+    }
+
+    addPlane = () => {
+        const locis: StructureElement.Loci[] = [];
+        this.plugin.managers.structure.selection.entries.forEach(v => {
+            locis.push(v.selection);
+        });
+        this.plugin.managers.structure.measurement.addPlane(locis);
+    }
+
     get actions(): ActionMenu.Items {
         const history = this.selection.additionsHistory;
         const ret: ActionMenu.Item[] = [
-            { kind: 'item', label: `Label ${history.length === 0 ? ' (1 selection required)' : ' (1st selection)'}`, value: this.addLabel, disabled: history.length === 0 },
-            { kind: 'item', label: `Distance ${history.length < 2 ? ' (2 selections required)' : ' (top 2 selections)'}`, value: this.measureDistance, disabled: history.length < 2 },
-            { kind: 'item', label: `Angle ${history.length < 3 ? ' (3 selections required)' : ' (top 3 selections)'}`, value: this.measureAngle, disabled: history.length < 3 },
-            { kind: 'item', label: `Dihedral ${history.length < 4 ? ' (4 selections required)' : ' (top 4 selections)'}`, value: this.measureDihedral, disabled: history.length < 4 },
+            { kind: 'item', label: `Label ${history.length === 0 ? ' (1 selection item required)' : ' (1st selection item)'}`, value: this.addLabel, disabled: history.length === 0 },
+            { kind: 'item', label: `Distance ${history.length < 2 ? ' (2 selection items required)' : ' (top 2 selection items)'}`, value: this.measureDistance, disabled: history.length < 2 },
+            { kind: 'item', label: `Angle ${history.length < 3 ? ' (3 selection items required)' : ' (top 3 items)'}`, value: this.measureAngle, disabled: history.length < 3 },
+            { kind: 'item', label: `Dihedral ${history.length < 4 ? ' (4 selection items required)' : ' (top 4 selection items)'}`, value: this.measureDihedral, disabled: history.length < 4 },
+            { kind: 'item', label: `Orientation ${history.length === 0 ? ' (selection required)' : ' (current selection)'}`, value: this.addOrientation, disabled: history.length === 0 },
+            { kind: 'item', label: `Plane ${history.length === 0 ? ' (selection required)' : ' (current selection)'}`, value: this.addPlane, disabled: history.length === 0 },
         ];
         return ret;
     }
@@ -219,7 +240,7 @@ class MeasurementEntry extends PurePluginUIComponent<{ cell: StructureMeasuremen
     }
 
     get selections() {
-        return this.props.cell.obj?.data.sourceData as Partial<DistanceData & AngleData & DihedralData & LabelData> | undefined;
+        return this.props.cell.obj?.data.sourceData as Partial<DistanceData & AngleData & DihedralData & LabelData & OrientationData> | undefined;
     }
 
     delete = () => {
@@ -266,6 +287,7 @@ class MeasurementEntry extends PurePluginUIComponent<{ cell: StructureMeasuremen
         if (selections.pairs) return selections.pairs[0].loci;
         if (selections.triples) return selections.triples[0].loci;
         if (selections.quads) return selections.quads[0].loci;
+        if (selections.locis) return selections.locis;
         return [];
     }
 
@@ -277,6 +299,7 @@ class MeasurementEntry extends PurePluginUIComponent<{ cell: StructureMeasuremen
         if (selections.pairs) return distanceLabel(selections.pairs[0], { condensed: true, unitLabel: this.plugin.managers.structure.measurement.state.options.distanceUnitLabel });
         if (selections.triples) return angleLabel(selections.triples[0], { condensed: true });
         if (selections.quads) return dihedralLabel(selections.quads[0], { condensed: true });
+        if (selections.locis) return structureElementLociLabelMany(selections.locis, { countsOnly: true });
         return '<empty>';
     }
 

+ 45 - 57
src/mol-repr/shape/loci/orientation.ts

@@ -1,10 +1,9 @@
 /**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import { Loci } from '../../../mol-model/loci';
 import { RuntimeContext } from '../../../mol-task';
 import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { ColorNames } from '../../../mol-util/color/names';
@@ -13,21 +12,23 @@ import { Representation, RepresentationParamsGetter, RepresentationContext } fro
 import { Shape } from '../../../mol-model/shape';
 import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
 import { MeshBuilder } from '../../../mol-geo/geometry/mesh/mesh-builder';
-import { lociLabel } from '../../../mol-theme/label';
+import { structureElementLociLabelMany } from '../../../mol-theme/label';
 import { addAxes } from '../../../mol-geo/geometry/mesh/builder/axes';
 import { addOrientedBox } from '../../../mol-geo/geometry/mesh/builder/box';
 import { addEllipsoid } from '../../../mol-geo/geometry/mesh/builder/ellipsoid';
 import { Axes3D } from '../../../mol-math/geometry';
 import { Vec3 } from '../../../mol-math/linear-algebra';
 import { MarkerActions } from '../../../mol-util/marker-action';
+import { StructureElement } from '../../../mol-model/structure';
 
 export interface OrientationData {
-    locis: Loci[]
+    locis: StructureElement.Loci[]
 }
 
 const SharedParams = {
     color: PD.Color(ColorNames.orange),
-    scale: PD.Numeric(2, { min: 0.1, max: 10, step: 0.1 })
+    scaleFactor: PD.Numeric(1, { min: 0.1, max: 10, step: 0.1 }),
+    radiusScale: PD.Numeric(2, { min: 0.1, max: 10, step: 0.1 })
 };
 
 const AxesParams = {
@@ -57,97 +58,84 @@ const OrientationVisuals = {
 export const OrientationParams = {
     ...AxesParams,
     ...BoxParams,
+    ...EllipsoidParams,
     visuals: PD.MultiSelect(['box'], PD.objectToOptions(OrientationVisuals)),
-    color: PD.Color(ColorNames.orange),
-    scale: PD.Numeric(2, { min: 0.1, max: 5, step: 0.1 })
 };
 export type OrientationParams = typeof OrientationParams
 export type OrientationProps = PD.Values<OrientationParams>
 
 //
 
-function orientationLabel(loci: Loci) {
-    const label = lociLabel(loci, { countsOnly: true });
+function getAxesName(locis: StructureElement.Loci[]) {
+    const label = structureElementLociLabelMany(locis, { countsOnly: true });
     return `Principal Axes of ${label}`;
 }
 
-function getOrientationName(data: OrientationData) {
-    return data.locis.length === 1 ? orientationLabel(data.locis[0]) : `${data.locis.length} Orientations`;
-}
-
-//
-
 function buildAxesMesh(data: OrientationData, props: OrientationProps, mesh?: Mesh): Mesh {
     const state = MeshBuilder.createState(256, 128, mesh);
-    for (let i = 0, il = data.locis.length; i < il; ++i) {
-        const principalAxes = Loci.getPrincipalAxes(data.locis[i]);
-        if (principalAxes) {
-            state.currentGroup = i;
-            addAxes(state, principalAxes.momentsAxes, props.scale, 2, 20);
-        }
-    }
+    const principalAxes = StructureElement.Loci.getPrincipalAxesMany(data.locis);
+    Axes3D.scale(principalAxes.momentsAxes, principalAxes.momentsAxes, props.scaleFactor);
+
+    state.currentGroup = 0;
+    addAxes(state, principalAxes.momentsAxes, props.radiusScale, 2, 20);
     return MeshBuilder.getMesh(state);
 }
 
 function getAxesShape(ctx: RuntimeContext, data: OrientationData, props: OrientationProps, shape?: Shape<Mesh>) {
     const mesh = buildAxesMesh(data, props, shape && shape.geometry);
-    const name = getOrientationName(data);
-    const getLabel = function (groupId: number) {
-        return orientationLabel(data.locis[groupId]);
-    };
-    return Shape.create(name, data, mesh, () => props.color, () => 1, getLabel);
+    const name = getAxesName(data.locis);
+    return Shape.create(name, data, mesh, () => props.color, () => 1, () => name);
 }
 
 //
 
+function getBoxName(locis: StructureElement.Loci[]) {
+    const label = structureElementLociLabelMany(locis, { countsOnly: true });
+    return `Oriented Box of ${label}`;
+}
+
 function buildBoxMesh(data: OrientationData, props: OrientationProps, mesh?: Mesh): Mesh {
     const state = MeshBuilder.createState(256, 128, mesh);
-    for (let i = 0, il = data.locis.length; i < il; ++i) {
-        const principalAxes = Loci.getPrincipalAxes(data.locis[i]);
-        if (principalAxes) {
-            state.currentGroup = i;
-            addOrientedBox(state, principalAxes.boxAxes, props.scale, 2, 20);
-        }
-    }
+    const principalAxes = StructureElement.Loci.getPrincipalAxesMany(data.locis);
+    Axes3D.scale(principalAxes.boxAxes, principalAxes.boxAxes, props.scaleFactor);
+
+    state.currentGroup = 0;
+    addOrientedBox(state, principalAxes.boxAxes, props.radiusScale, 2, 20);
     return MeshBuilder.getMesh(state);
 }
 
 function getBoxShape(ctx: RuntimeContext, data: OrientationData, props: OrientationProps, shape?: Shape<Mesh>) {
     const mesh = buildBoxMesh(data, props, shape && shape.geometry);
-    const name = getOrientationName(data);
-    const getLabel = function (groupId: number) {
-        return orientationLabel(data.locis[groupId]);
-    };
-    return Shape.create(name, data, mesh, () => props.color, () => 1, getLabel);
+    const name = getBoxName(data.locis);
+    return Shape.create(name, data, mesh, () => props.color, () => 1, () => name);
 }
 
 //
 
+function getEllipsoidName(locis: StructureElement.Loci[]) {
+    const label = structureElementLociLabelMany(locis, { countsOnly: true });
+    return `Oriented Ellipsoid of ${label}`;
+}
+
 function buildEllipsoidMesh(data: OrientationData, props: OrientationProps, mesh?: Mesh): Mesh {
     const state = MeshBuilder.createState(256, 128, mesh);
-    for (let i = 0, il = data.locis.length; i < il; ++i) {
-        const principalAxes = Loci.getPrincipalAxes(data.locis[i]);
-        if (principalAxes) {
-            const axes = principalAxes.boxAxes;
-            const { origin, dirA, dirB } = axes;
-            const size = Axes3D.size(Vec3(), axes);
-            Vec3.scale(size, size, 0.5);
-            const radiusScale = Vec3.create(size[2], size[1], size[0]);
-
-            state.currentGroup = i;
-            addEllipsoid(state, origin, dirA, dirB, radiusScale, 2);
-        }
-    }
+    const principalAxes = StructureElement.Loci.getPrincipalAxesMany(data.locis);
+
+    const axes = principalAxes.boxAxes;
+    const { origin, dirA, dirB } = axes;
+    const size = Axes3D.size(Vec3(), axes);
+    Vec3.scale(size, size, 0.5 * props.scaleFactor);
+    const radiusScale = Vec3.create(size[2], size[1], size[0]);
+
+    state.currentGroup = 0;
+    addEllipsoid(state, origin, dirA, dirB, radiusScale, 2);
     return MeshBuilder.getMesh(state);
 }
 
 function getEllipsoidShape(ctx: RuntimeContext, data: OrientationData, props: OrientationProps, shape?: Shape<Mesh>) {
     const mesh = buildEllipsoidMesh(data, props, shape && shape.geometry);
-    const name = getOrientationName(data);
-    const getLabel = function (groupId: number) {
-        return orientationLabel(data.locis[groupId]);
-    };
-    return Shape.create(name, data, mesh, () => props.color, () => 1, getLabel);
+    const name = getEllipsoidName(data.locis);
+    return Shape.create(name, data, mesh, () => props.color, () => 1, () => name);
 }
 
 //

+ 84 - 0
src/mol-repr/shape/loci/plane.ts

@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { RuntimeContext } from '../../../mol-task';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+import { ColorNames } from '../../../mol-util/color/names';
+import { ShapeRepresentation } from '../representation';
+import { Representation, RepresentationParamsGetter, RepresentationContext } from '../../representation';
+import { Shape } from '../../../mol-model/shape';
+import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
+import { MeshBuilder } from '../../../mol-geo/geometry/mesh/mesh-builder';
+import { structureElementLociLabelMany } from '../../../mol-theme/label';
+import { Mat4, Vec3 } from '../../../mol-math/linear-algebra';
+import { MarkerActions } from '../../../mol-util/marker-action';
+import { Plane } from '../../../mol-geo/primitive/plane';
+import { StructureElement } from '../../../mol-model/structure';
+import { Axes3D } from '../../../mol-math/geometry';
+
+export interface PlaneData {
+    locis: StructureElement.Loci[]
+}
+
+const _PlaneParams = {
+    ...Mesh.Params,
+    color: PD.Color(ColorNames.orange),
+    scaleFactor: PD.Numeric(1, { min: 0.1, max: 10, step: 0.1 }),
+};
+type _PlaneParams = typeof _PlaneParams
+
+const PlaneVisuals = {
+    'plane': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<PlaneData, _PlaneParams>) => ShapeRepresentation(getPlaneShape, Mesh.Utils),
+};
+
+export const PlaneParams = {
+    ..._PlaneParams,
+    visuals: PD.MultiSelect(['plane'], PD.objectToOptions(PlaneVisuals)),
+};
+export type PlaneParams = typeof PlaneParams
+export type PlaneProps = PD.Values<PlaneParams>
+
+//
+
+function getPlaneName(locis: StructureElement.Loci[]) {
+    const label = structureElementLociLabelMany(locis, { countsOnly: true });
+    return `Best Fit Plane of ${label}`;
+}
+
+const tmpMat = Mat4();
+const tmpV = Vec3();
+function buildPlaneMesh(data: PlaneData, props: PlaneProps, mesh?: Mesh): Mesh {
+    const state = MeshBuilder.createState(256, 128, mesh);
+    const principalAxes = StructureElement.Loci.getPrincipalAxesMany(data.locis);
+    const axes = principalAxes.boxAxes;
+    const plane = Plane();
+
+    Vec3.add(tmpV, axes.origin, axes.dirC);
+    Mat4.targetTo(tmpMat, tmpV, axes.origin, axes.dirB);
+    Mat4.scale(tmpMat, tmpMat, Axes3D.size(tmpV, axes));
+    Mat4.scaleUniformly(tmpMat, tmpMat, props.scaleFactor);
+    Mat4.setTranslation(tmpMat, axes.origin);
+
+    state.currentGroup = 0;
+    MeshBuilder.addPrimitive(state, tmpMat, plane);
+    MeshBuilder.addPrimitiveFlipped(state, tmpMat, plane);
+    return MeshBuilder.getMesh(state);
+}
+
+function getPlaneShape(ctx: RuntimeContext, data: PlaneData, props: PlaneProps, shape?: Shape<Mesh>) {
+    const mesh = buildPlaneMesh(data, props, shape && shape.geometry);
+    const name = getPlaneName(data.locis);
+    return Shape.create(name, data, mesh, () => props.color, () => 1, () => name);
+}
+
+//
+
+export type PlaneRepresentation = Representation<PlaneData, PlaneParams>
+export function PlaneRepresentation(ctx: RepresentationContext, getParams: RepresentationParamsGetter<PlaneData, PlaneParams>): PlaneRepresentation {
+    const repr = Representation.createMulti('Plane', ctx, getParams, Representation.StateBuilder, PlaneVisuals as unknown as Representation.Def<PlaneData, PlaneParams>);
+    repr.setState({ markerActions: MarkerActions.Highlighting });
+    return repr;
+}

+ 8 - 0
src/mol-theme/label.ts

@@ -92,6 +92,14 @@ export function structureElementStatsLabel(stats: StructureElement.Stats, option
     return o.htmlStyling ? label : stripTags(label);
 }
 
+export function structureElementLociLabelMany(locis: StructureElement.Loci[], options: Partial<LabelOptions> = {}): string {
+    const stats = StructureElement.Stats.create();
+    for (const l of locis) {
+        StructureElement.Stats.add(stats, stats, StructureElement.Stats.ofLoci(l));
+    }
+    return structureElementStatsLabel(stats, options);
+}
+
 function _structureElementStatsLabel(stats: StructureElement.Stats, countsOnly = false, hidePrefix = false, condensed = false, reverse = false): string {
     const { structureCount, chainCount, residueCount, conformationCount, elementCount } = stats;