diff --git a/dist/mixins/group_transform.d.ts b/dist/mixins/group_transform.d.ts new file mode 100644 index 00000000..c4b91910 --- /dev/null +++ b/dist/mixins/group_transform.d.ts @@ -0,0 +1,60 @@ +import * as THREE from "three"; +/** + * Mixin providing rigid group transforms (translate/rotate about a shared centroid pivot) for + * InteractiveStructureEditorMixin's multi-atom selection: a 2+ atom selection drags or rotates + * together as one commit, matching the old editor's MultipleSelectionControls pivot-group + * behavior (decision D-4). Composed alongside InteractiveStructureEditorMixin, which owns the + * TransformControls lifecycle listeners and the direct-drag pointer handlers that call into this + * mixin's helpers, and shares its `this` - selectedMeshes_, transformControls_, + * commitMovedAtoms_, etc. all live on the base mixin. + */ +export declare const GroupTransformMixin: (superclass: any) => { + new (config: any): { + [x: string]: any; + selectionPivot_: THREE.Object3D | null; + groupDragStartPositions_: Map | null; + isDraggingGroup_: boolean; + /** + * Repositions the group-transform pivot at the current selection's centroid and attaches + * the gizmo to it. + */ + attachPivotToSelection_(): void; + /** + * Shared by attachPivotToSelection_ and the group direct-drag move handler, which must + * keep the pivot (and therefore the visible gizmo) tracking the group's centroid as it + * moves - without this, the gizmo would stay frozen at the pre-drag centroid for the + * whole gesture and only jump to the correct spot once the post-commit rebuild reattaches + * it. + */ + computeCentroid_(meshes: THREE.Mesh[]): THREE.Vector3; + /** + * Snapshots every selected atom's current position, keyed by mesh, so a group gizmo or + * direct drag can compute each frame's delta against a fixed start rather than the + * previous frame (which would accumulate rounding error) and so Esc can revert cleanly. + */ + captureGroupDragStartPositions_(): void; + /** + * Called on every TransformControls "change" event while the group pivot is the object + * being dragged: propagates the pivot's live translate/rotate delta to every selected + * atom so the group moves rigidly together during the gesture, not just once on commit. + * Translate mode applies the pivot's position delta to each atom; rotate mode leaves the + * pivot's position fixed at the centroid and instead rotates each atom's offset from it + * by the pivot's quaternion, so the group rotates rigidly about its shared centroid. + */ + handleGroupPivotChange_(): void; + /** + * Commits a completed group gizmo drag (translate or rotate) as a single delta/commit - + * called from the TransformControls "mouseUp" listener once it's determined the pivot + * (not a lone atom) was the dragged object and it actually moved/rotated. + */ + commitGroupGizmoDrag_(wasRotate: boolean): void; + /** + * If a group direct-drag (not the gizmo) is in progress, moves every selected atom by + * the same delta as the pressed atom and keeps the pivot tracking the live centroid. + * Returns whether it applied - the caller falls back to moving just the pending atom + * when this returns false, matching a lone-atom (or gizmo) drag. + */ + applyGroupDragDelta_(newPosition: THREE.Vector3): boolean; + }; + [x: string]: any; +}; diff --git a/dist/mixins/group_transform.js b/dist/mixins/group_transform.js new file mode 100644 index 00000000..af3a6cb3 --- /dev/null +++ b/dist/mixins/group_transform.js @@ -0,0 +1,146 @@ +import * as THREE from "three"; +/** + * Mixin providing rigid group transforms (translate/rotate about a shared centroid pivot) for + * InteractiveStructureEditorMixin's multi-atom selection: a 2+ atom selection drags or rotates + * together as one commit, matching the old editor's MultipleSelectionControls pivot-group + * behavior (decision D-4). Composed alongside InteractiveStructureEditorMixin, which owns the + * TransformControls lifecycle listeners and the direct-drag pointer handlers that call into this + * mixin's helpers, and shares its `this` - selectedMeshes_, transformControls_, + * commitMovedAtoms_, etc. all live on the base mixin. + */ +export const GroupTransformMixin = (superclass) => class extends superclass { + constructor(config) { + super(config); + this.selectionPivot_ = null; + this.groupDragStartPositions_ = null; + this.isDraggingGroup_ = false; + } + /** + * Repositions the group-transform pivot at the current selection's centroid and attaches + * the gizmo to it. + */ + attachPivotToSelection_() { + if (!this.selectionPivot_ || this.selectedMeshes_.length === 0) + return; + this.selectionPivot_.position.copy(this.computeCentroid_(this.selectedMeshes_)); + // A fresh attach always starts unrotated, even if a previous group drag left the + // pivot's quaternion non-identity for any reason (the mouseUp handler already resets + // it after every rotate commit - this is a defensive backstop, not the primary path). + this.selectionPivot_.quaternion.identity(); + if (this.transformControls_) + this.transformControls_.attach(this.selectionPivot_); + } + /** + * Shared by attachPivotToSelection_ and the group direct-drag move handler, which must + * keep the pivot (and therefore the visible gizmo) tracking the group's centroid as it + * moves - without this, the gizmo would stay frozen at the pre-drag centroid for the + * whole gesture and only jump to the correct spot once the post-commit rebuild reattaches + * it. + */ + // eslint-disable-next-line class-methods-use-this + computeCentroid_(meshes) { + const centroid = new THREE.Vector3(); + meshes.forEach((mesh) => centroid.add(mesh.position)); + return centroid.divideScalar(meshes.length); + } + /** + * Snapshots every selected atom's current position, keyed by mesh, so a group gizmo or + * direct drag can compute each frame's delta against a fixed start rather than the + * previous frame (which would accumulate rounding error) and so Esc can revert cleanly. + */ + captureGroupDragStartPositions_() { + this.groupDragStartPositions_ = new Map(this.selectedMeshes_.map((mesh) => [mesh, mesh.position.clone()])); + } + /** + * Called on every TransformControls "change" event while the group pivot is the object + * being dragged: propagates the pivot's live translate/rotate delta to every selected + * atom so the group moves rigidly together during the gesture, not just once on commit. + * Translate mode applies the pivot's position delta to each atom; rotate mode leaves the + * pivot's position fixed at the centroid and instead rotates each atom's offset from it + * by the pivot's quaternion, so the group rotates rigidly about its shared centroid. + */ + handleGroupPivotChange_() { + var _a; + // Captured as a local so its non-null-ness (checked right below) narrows reliably - + // transformControls_/transformDragStartPosition_ are declared on a sibling mixin + // class (InteractiveStructureEditorMixin), not this one, so TS sees them as `any` + // here and won't propagate narrowing from a compound guard into selectionPivot_. + const pivot = this.selectionPivot_; + if (!pivot || + !((_a = this.transformControls_) === null || _a === void 0 ? void 0 : _a.dragging) || + this.transformControls_.object !== pivot || + !this.groupDragStartPositions_ || + !this.transformDragStartPosition_) { + return; + } + if (this.transformControls_.mode === "rotate") { + const pivotCenter = this.transformDragStartPosition_; + const rotation = pivot.quaternion; + this.selectedMeshes_.forEach((mesh) => { + var _a; + const start = (_a = this.groupDragStartPositions_) === null || _a === void 0 ? void 0 : _a.get(mesh); + if (!start) + return; + const offset = start.clone().sub(pivotCenter).applyQuaternion(rotation); + mesh.position.copy(pivotCenter.clone().add(offset)); + }); + } + else { + const delta = pivot.position.clone().sub(this.transformDragStartPosition_); + this.selectedMeshes_.forEach((mesh) => { + var _a; + const start = (_a = this.groupDragStartPositions_) === null || _a === void 0 ? void 0 : _a.get(mesh); + if (start) + mesh.position.copy(start.clone().add(delta)); + }); + } + this.syncHighlightPoolTo_(this.selectedMeshes_); + } + /** + * Commits a completed group gizmo drag (translate or rotate) as a single delta/commit - + * called from the TransformControls "mouseUp" listener once it's determined the pivot + * (not a lone atom) was the dragged object and it actually moved/rotated. + */ + commitGroupGizmoDrag_(wasRotate) { + var _a; + this.commitMovedAtoms_(this.selectedMeshes_.map((mesh) => ({ + atomicIndex: mesh.userData.atomicIndex, + position: mesh.position.clone(), + })), "gizmo"); + if (wasRotate) { + // The pivot's rotation is relative to each drag, not cumulative across drags - + // the atoms' new positions already encode the rotation, so reset it to identity + // for the next attach/drag. Optional-chained defensively: this is only ever + // called once the caller has confirmed selectionPivot_ was the dragged object, + // but that guard lives in the caller, not in this method's own type narrowing. + (_a = this.selectionPivot_) === null || _a === void 0 ? void 0 : _a.quaternion.identity(); + } + } + /** + * If a group direct-drag (not the gizmo) is in progress, moves every selected atom by + * the same delta as the pressed atom and keeps the pivot tracking the live centroid. + * Returns whether it applied - the caller falls back to moving just the pending atom + * when this returns false, matching a lone-atom (or gizmo) drag. + */ + applyGroupDragDelta_(newPosition) { + if (!this.isDraggingGroup_ || + !this.groupDragStartPositions_ || + !this.pendingDragAtom_) { + return false; + } + const draggedStart = this.groupDragStartPositions_.get(this.pendingDragAtom_); + if (draggedStart) { + const delta = newPosition.clone().sub(draggedStart); + this.selectedMeshes_.forEach((mesh) => { + var _a; + const start = (_a = this.groupDragStartPositions_) === null || _a === void 0 ? void 0 : _a.get(mesh); + if (start) + mesh.position.copy(start.clone().add(delta)); + }); + if (this.selectionPivot_) { + this.selectionPivot_.position.copy(this.computeCentroid_(this.selectedMeshes_)); + } + } + return true; + } +}; diff --git a/dist/mixins/interactive_editor_constants.d.ts b/dist/mixins/interactive_editor_constants.d.ts new file mode 100644 index 00000000..01e6b897 --- /dev/null +++ b/dist/mixins/interactive_editor_constants.d.ts @@ -0,0 +1,7 @@ +/** + * Pixel distance a pointer must travel before a press-and-move gesture commits to a drag + * (atom drag or marquee rubber-band) instead of registering as a plain click on release. + * Shared between InteractiveStructureEditorMixin (atom drag) and MarqueeSelectionMixin + * (marquee activation), which otherwise have no dependency on each other. + */ +export declare const DRAG_THRESHOLD_PX = 5; diff --git a/dist/mixins/interactive_editor_constants.js b/dist/mixins/interactive_editor_constants.js new file mode 100644 index 00000000..fb68a29b --- /dev/null +++ b/dist/mixins/interactive_editor_constants.js @@ -0,0 +1,7 @@ +/** + * Pixel distance a pointer must travel before a press-and-move gesture commits to a drag + * (atom drag or marquee rubber-band) instead of registering as a plain click on release. + * Shared between InteractiveStructureEditorMixin (atom drag) and MarqueeSelectionMixin + * (marquee activation), which otherwise have no dependency on each other. + */ +export const DRAG_THRESHOLD_PX = 5; diff --git a/dist/mixins/interactive_structure_editor.d.ts b/dist/mixins/interactive_structure_editor.d.ts index 49eb6658..0bd99fcd 100644 --- a/dist/mixins/interactive_structure_editor.d.ts +++ b/dist/mixins/interactive_structure_editor.d.ts @@ -18,7 +18,6 @@ export declare const InteractiveStructureEditorMixin: (superclass: any) => { hoveredMesh_: THREE.Mesh | null; selectionHighlightPool_: THREE.Mesh[]; hoverHighlightMesh_: THREE.Mesh | null; - selectionPivot_: THREE.Object3D | null; isEditModeEnabled_: boolean; pointerDownPosition_: { x: number; @@ -26,8 +25,6 @@ export declare const InteractiveStructureEditorMixin: (superclass: any) => { } | null; pendingDragAtom_: THREE.Mesh | null; isDraggingAtom_: boolean; - isDraggingGroup_: boolean; - groupDragStartPositions_: Map | null; dragPlane_: THREE.Plane | null; dragOffset_: THREE.Vector3 | null; dragStartPosition_: THREE.Vector3 | null; @@ -35,14 +32,6 @@ export declare const InteractiveStructureEditorMixin: (superclass: any) => { orbitControlsEnabledBeforeDrag_: boolean; orbitControlsDefaultMouseButtons_: any | null; lastSelectedAtomicIndices_: number[] | null; - marqueeStartScreen_: { - x: number; - y: number; - } | null; - isMarqueeSelecting_: boolean; - marqueeOverlayElement_: HTMLDivElement | null; - marqueeModifierAdd_: boolean; - marqueeModifierToggle_: boolean; handlePointerDownCapture_: ((event: PointerEvent) => void) | null; handlePointerMoveCapture_: ((event: PointerEvent) => void) | null; handlePointerUpCapture_: ((event: PointerEvent) => void) | null; @@ -90,34 +79,6 @@ export declare const InteractiveStructureEditorMixin: (superclass: any) => { * the hot path of an in-progress drag. */ updateHoverFromPointer_(event: PointerEvent): void; - /** - * Grows the marquee's screen-space rectangle as the pointer moves, activating it (and - * showing the overlay) only once the drag exceeds the same click-vs-drag threshold used - * for atom dragging, so a plain click on empty space still falls through to - * finishMarqueeSelection_'s deselect path instead of drawing a zero-size box. - */ - updateMarqueeState_(event: PointerEvent): void; - showMarqueeOverlay_(): void; - updateMarqueeOverlay_(currentX: number, currentY: number): void; - hideMarqueeOverlay_(): void; - /** - * Returns every atom whose projected screen position falls within the given - * (unordered) screen-space rectangle. Atoms behind the camera (or beyond the far - * plane) are excluded via the projected z check. - */ - getAtomsInScreenRect_(rect: { - left: number; - right: number; - top: number; - bottom: number; - }): THREE.Mesh[]; - /** - * Resolves a completed (or abandoned) marquee gesture. A release before crossing the - * drag threshold is just a plain click on empty space, so it falls through to the - * existing handlePointerDown click-to-deselect/select path rather than selecting an - * empty rectangle. - */ - finishMarqueeSelection_(event: PointerEvent): void; /** * Selects the pending atom and sets up the camera-facing drag plane through its current * position, offset so the atom doesn't jump to snap its center to the cursor. If the @@ -177,19 +138,6 @@ export declare const InteractiveStructureEditorMixin: (superclass: any) => { * (see beginAtomDrag_/the TransformControls "change"/"mouseUp" listeners). */ setSelectedAtomMeshes(meshes: THREE.Mesh[]): void; - /** - * Repositions the group-transform pivot at the current selection's centroid and attaches - * the gizmo to it. - */ - attachPivotToSelection_(): void; - /** - * Shared by attachPivotToSelection_ and the group direct-drag move handler, which must - * keep the pivot (and therefore the visible gizmo) tracking the group's centroid as it - * moves - without this, the gizmo would stay frozen at the pre-drag centroid for the - * whole gesture and only jump to the correct spot once the post-commit rebuild reattaches - * it. - */ - computeCentroid_(meshes: THREE.Mesh[]): THREE.Vector3; /** * Clears the current atom selection, removes its highlight(s), and detaches the gizmo. * @param {boolean} forgetLastSelection - Also forget the remembered indices used to diff --git a/dist/mixins/interactive_structure_editor.js b/dist/mixins/interactive_structure_editor.js index 729e050b..1ed8e31a 100644 --- a/dist/mixins/interactive_structure_editor.js +++ b/dist/mixins/interactive_structure_editor.js @@ -1,12 +1,10 @@ import * as THREE from "three"; import { TransformControls } from "three/examples/jsm/controls/TransformControls"; +import { DRAG_THRESHOLD_PX } from "./interactive_editor_constants"; const HOVER_HIGHLIGHT_COLOR = 0x54aeff; const SELECTION_HIGHLIGHT_COLOR = 0x0969da; const HIGHLIGHT_SCALE_FACTOR = 1.25; -const DRAG_THRESHOLD_PX = 5; const DRAG_COMMIT_EPSILON = 1e-6; -const MARQUEE_FILL_COLOR = "rgba(84, 174, 255, 0.15)"; -const MARQUEE_BORDER_COLOR = "#54aeff"; /** * Mixin providing interactive structure editing capabilities inside the Wave visualizer. * Enforces strict object-oriented design and follows the "6 months x 3 beers" rule for comments. @@ -24,13 +22,10 @@ export const InteractiveStructureEditorMixin = (superclass) => class extends sup this.hoveredMesh_ = null; this.selectionHighlightPool_ = []; this.hoverHighlightMesh_ = null; - this.selectionPivot_ = null; this.isEditModeEnabled_ = false; this.pointerDownPosition_ = null; this.pendingDragAtom_ = null; this.isDraggingAtom_ = false; - this.isDraggingGroup_ = false; - this.groupDragStartPositions_ = null; this.dragPlane_ = null; this.dragOffset_ = null; this.dragStartPosition_ = null; @@ -38,11 +33,6 @@ export const InteractiveStructureEditorMixin = (superclass) => class extends sup this.orbitControlsEnabledBeforeDrag_ = true; this.orbitControlsDefaultMouseButtons_ = null; this.lastSelectedAtomicIndices_ = null; - this.marqueeStartScreen_ = null; - this.isMarqueeSelecting_ = false; - this.marqueeOverlayElement_ = null; - this.marqueeModifierAdd_ = false; - this.marqueeModifierToggle_ = false; this.handlePointerDownCapture_ = null; this.handlePointerMoveCapture_ = null; this.handlePointerUpCapture_ = null; @@ -75,44 +65,11 @@ export const InteractiveStructureEditorMixin = (superclass) => class extends sup this.hoverHighlightMesh_ = this.createHighlightMesh_(HOVER_HIGHLIGHT_COLOR, 0.5); this.scene.add(this.hoverHighlightMesh_); // Rerender the viewport on every translation/rotation frame update; while dragging the - // group pivot, also propagate its live delta to every selected atom so they move - // rigidly together during the gizmo drag, not just once on commit. Translate mode - // moves the pivot's position (apply the same position delta to each atom); rotate - // mode leaves the pivot's position fixed at the centroid and instead rotates its - // quaternion (rotate each atom's offset-from-centroid by that same quaternion, so the - // group rotates rigidly about its shared centroid - the old editor's pivot-group - // behavior, decision D-4's "group rotate" half). + // group pivot, handleGroupPivotChange_ (GroupTransformMixin) also propagates its live + // delta to every selected atom so they move rigidly together during the gizmo drag, + // not just once on commit (decision D-4's "group rotate" half). this.transformControls_.addEventListener("change", () => { - var _a; - if (((_a = this.transformControls_) === null || _a === void 0 ? void 0 : _a.dragging) && - this.transformControls_.object === this.selectionPivot_ && - this.groupDragStartPositions_ && - this.transformDragStartPosition_) { - if (this.transformControls_.mode === "rotate") { - const pivotCenter = this.transformDragStartPosition_; - const rotation = this.selectionPivot_.quaternion; - this.selectedMeshes_.forEach((mesh) => { - var _a; - const start = (_a = this.groupDragStartPositions_) === null || _a === void 0 ? void 0 : _a.get(mesh); - if (!start) - return; - const offset = start.clone().sub(pivotCenter).applyQuaternion(rotation); - mesh.position.copy(pivotCenter.clone().add(offset)); - }); - } - else { - const delta = this.selectionPivot_.position - .clone() - .sub(this.transformDragStartPosition_); - this.selectedMeshes_.forEach((mesh) => { - var _a; - const start = (_a = this.groupDragStartPositions_) === null || _a === void 0 ? void 0 : _a.get(mesh); - if (start) - mesh.position.copy(start.clone().add(delta)); - }); - } - this.syncHighlightPoolTo_(this.selectedMeshes_); - } + this.handleGroupPivotChange_(); this.render(); }); // Disables the OrbitControls while dragging an atom to avoid camera movement conflicts @@ -130,10 +87,7 @@ export const InteractiveStructureEditorMixin = (superclass) => class extends sup this.transformDragStartQuaternion_ = (_h = (_g = (_f = this.transformControls_) === null || _f === void 0 ? void 0 : _f.object) === null || _g === void 0 ? void 0 : _g.quaternion.clone()) !== null && _h !== void 0 ? _h : null; if (((_j = this.transformControls_) === null || _j === void 0 ? void 0 : _j.object) === this.selectionPivot_) { - this.groupDragStartPositions_ = new Map(this.selectedMeshes_.map((mesh) => [ - mesh, - mesh.position.clone(), - ])); + this.captureGroupDragStartPositions_(); } } else { @@ -162,16 +116,7 @@ export const InteractiveStructureEditorMixin = (superclass) => class extends sup draggedObject.quaternion.angleTo(startQuaternion) > DRAG_COMMIT_EPSILON; if ((hasMoved || hasRotated) && draggedObject) { if (draggedObject === this.selectionPivot_) { - this.commitMovedAtoms_(this.selectedMeshes_.map((mesh) => ({ - atomicIndex: mesh.userData.atomicIndex, - position: mesh.position.clone(), - })), "gizmo"); - if (wasRotate) { - // The pivot's rotation is relative to each drag, not cumulative - // across drags - the atoms' new positions already encode the - // rotation, so reset it to identity for the next attach/drag. - this.selectionPivot_.quaternion.identity(); - } + this.commitGroupGizmoDrag_(wasRotate); } else { this.commitMovedAtom_(draggedObject.userData.atomicIndex, draggedObject.position, "gizmo"); @@ -301,27 +246,10 @@ export const InteractiveStructureEditorMixin = (superclass) => class extends sup if (!point) return; const newPosition = point.add(this.dragOffset_); - if (this.isDraggingGroup_ && - this.groupDragStartPositions_ && - this.pendingDragAtom_) { - const draggedStart = this.groupDragStartPositions_.get(this.pendingDragAtom_); - if (draggedStart) { - const delta = newPosition.clone().sub(draggedStart); - this.selectedMeshes_.forEach((mesh) => { - var _a; - const start = (_a = this.groupDragStartPositions_) === null || _a === void 0 ? void 0 : _a.get(mesh); - if (start) - mesh.position.copy(start.clone().add(delta)); - }); - // Keep the gizmo (attached to the pivot, not to any one dragged mesh) - // visually tracking the group instead of staying frozen at the pre-drag - // centroid for the whole gesture. - if (this.selectionPivot_) { - this.selectionPivot_.position.copy(this.computeCentroid_(this.selectedMeshes_)); - } - } - } - else { + // applyGroupDragDelta_ (GroupTransformMixin) also keeps the gizmo (attached to + // the pivot, not to any one dragged mesh) visually tracking the group instead of + // staying frozen at the pre-drag centroid for the whole gesture. + if (!this.applyGroupDragDelta_(newPosition)) { this.pendingDragAtom_.position.copy(newPosition); } this.syncHighlightPoolTo_(this.selectedMeshes_); @@ -412,124 +340,11 @@ export const InteractiveStructureEditorMixin = (superclass) => class extends sup this.renderer.domElement.style.cursor = atomUnderPointer ? "move" : ""; this.render(); } - // ---- Marquee (rubber-band) selection -------------------------------------------- - /** - * Grows the marquee's screen-space rectangle as the pointer moves, activating it (and - * showing the overlay) only once the drag exceeds the same click-vs-drag threshold used - * for atom dragging, so a plain click on empty space still falls through to - * finishMarqueeSelection_'s deselect path instead of drawing a zero-size box. - */ - updateMarqueeState_(event) { - if (!this.marqueeStartScreen_) - return; - const distance = Math.sqrt((event.clientX - this.marqueeStartScreen_.x) ** 2 + - (event.clientY - this.marqueeStartScreen_.y) ** 2); - if (!this.isMarqueeSelecting_) { - if (distance < DRAG_THRESHOLD_PX) - return; - this.isMarqueeSelecting_ = true; - this.showMarqueeOverlay_(); - } - this.updateMarqueeOverlay_(event.clientX, event.clientY); - } - showMarqueeOverlay_() { - if (!this.marqueeOverlayElement_) { - const element = document.createElement("div"); - element.style.position = "absolute"; - element.style.border = `1px solid ${MARQUEE_BORDER_COLOR}`; - element.style.backgroundColor = MARQUEE_FILL_COLOR; - element.style.pointerEvents = "none"; - element.style.zIndex = "10"; - this.container.appendChild(element); - this.marqueeOverlayElement_ = element; - } - this.marqueeOverlayElement_.style.display = "block"; - if (this.marqueeStartScreen_) { - this.updateMarqueeOverlay_(this.marqueeStartScreen_.x, this.marqueeStartScreen_.y); - } - } - updateMarqueeOverlay_(currentX, currentY) { - if (!this.marqueeOverlayElement_ || !this.marqueeStartScreen_) - return; - const containerRect = this.container.getBoundingClientRect(); - const left = Math.min(this.marqueeStartScreen_.x, currentX) - containerRect.left; - const top = Math.min(this.marqueeStartScreen_.y, currentY) - containerRect.top; - const width = Math.abs(currentX - this.marqueeStartScreen_.x); - const height = Math.abs(currentY - this.marqueeStartScreen_.y); - this.marqueeOverlayElement_.style.left = `${left}px`; - this.marqueeOverlayElement_.style.top = `${top}px`; - this.marqueeOverlayElement_.style.width = `${width}px`; - this.marqueeOverlayElement_.style.height = `${height}px`; - } - hideMarqueeOverlay_() { - if (this.marqueeOverlayElement_) - this.marqueeOverlayElement_.style.display = "none"; - } - /** - * Returns every atom whose projected screen position falls within the given - * (unordered) screen-space rectangle. Atoms behind the camera (or beyond the far - * plane) are excluded via the projected z check. - */ - getAtomsInScreenRect_(rect) { - const boundingRectangle = this.renderer.domElement.getBoundingClientRect(); - return this.collectSelectableAtoms().filter((atom) => { - const projected = atom.position.clone().project(this.camera); - if (projected.z < -1 || projected.z > 1) - return false; - const screenX = boundingRectangle.left + ((projected.x + 1) / 2) * boundingRectangle.width; - const screenY = boundingRectangle.top + ((1 - projected.y) / 2) * boundingRectangle.height; - return (screenX >= rect.left && - screenX <= rect.right && - screenY >= rect.top && - screenY <= rect.bottom); - }); - } - /** - * Resolves a completed (or abandoned) marquee gesture. A release before crossing the - * drag threshold is just a plain click on empty space, so it falls through to the - * existing handlePointerDown click-to-deselect/select path rather than selecting an - * empty rectangle. - */ - finishMarqueeSelection_(event) { - const wasSelecting = this.isMarqueeSelecting_; - const startScreen = this.marqueeStartScreen_; - const addModifier = this.marqueeModifierAdd_; - const toggleModifier = this.marqueeModifierToggle_; - this.hideMarqueeOverlay_(); - this.marqueeStartScreen_ = null; - this.isMarqueeSelecting_ = false; - if (!wasSelecting || !startScreen) { - this.handlePointerDown(event); - return; - } - const rect = { - left: Math.min(startScreen.x, event.clientX), - right: Math.max(startScreen.x, event.clientX), - top: Math.min(startScreen.y, event.clientY), - bottom: Math.max(startScreen.y, event.clientY), - }; - const hits = this.getAtomsInScreenRect_(rect); - let nextSelection; - if (toggleModifier) { - const hitSet = new Set(hits); - const kept = this.selectedMeshes_.filter((mesh) => !hitSet.has(mesh)); - const added = hits.filter((mesh) => !this.selectedMeshes_.includes(mesh)); - nextSelection = [...kept, ...added]; - } - else if (addModifier) { - const added = hits.filter((mesh) => !this.selectedMeshes_.includes(mesh)); - nextSelection = [...this.selectedMeshes_, ...added]; - } - else { - nextSelection = hits; - } - this.setSelectedAtomMeshes(nextSelection); - if (this.settings.onSelectionChanged) { - this.settings.onSelectionChanged(nextSelection.map((mesh) => mesh.userData.atomicIndex)); - } - this.render(); - } // ---- Direct atom drag (single or, for a multi-selected atom, the whole group) ----- + // + // Marquee (rubber-band) selection lives in MarqueeSelectionMixin (updateMarqueeState_, + // showMarqueeOverlay_/updateMarqueeOverlay_/hideMarqueeOverlay_, getAtomsInScreenRect_, + // finishMarqueeSelection_), called from the pointer capture handlers below. /** * Selects the pending atom and sets up the camera-facing drag plane through its current * position, offset so the atom doesn't jump to snap its center to the cursor. If the @@ -547,7 +362,7 @@ export const InteractiveStructureEditorMixin = (superclass) => class extends sup this.selectedMeshes_.length > 1 && this.selectedMeshes_.includes(this.pendingDragAtom_); if (this.isDraggingGroup_) { - this.groupDragStartPositions_ = new Map(this.selectedMeshes_.map((mesh) => [mesh, mesh.position.clone()])); + this.captureGroupDragStartPositions_(); } else { this.groupDragStartPositions_ = null; @@ -807,34 +622,6 @@ export const InteractiveStructureEditorMixin = (superclass) => class extends sup this.attachPivotToSelection_(); } } - /** - * Repositions the group-transform pivot at the current selection's centroid and attaches - * the gizmo to it. - */ - attachPivotToSelection_() { - if (!this.selectionPivot_ || this.selectedMeshes_.length === 0) - return; - this.selectionPivot_.position.copy(this.computeCentroid_(this.selectedMeshes_)); - // A fresh attach always starts unrotated, even if a previous group drag left the - // pivot's quaternion non-identity for any reason (the mouseUp handler already resets - // it after every rotate commit - this is a defensive backstop, not the primary path). - this.selectionPivot_.quaternion.identity(); - if (this.transformControls_) - this.transformControls_.attach(this.selectionPivot_); - } - /** - * Shared by attachPivotToSelection_ and the group direct-drag move handler, which must - * keep the pivot (and therefore the visible gizmo) tracking the group's centroid as it - * moves - without this, the gizmo would stay frozen at the pre-drag centroid for the - * whole gesture and only jump to the correct spot once the post-commit rebuild reattaches - * it. - */ - // eslint-disable-next-line class-methods-use-this - computeCentroid_(meshes) { - const centroid = new THREE.Vector3(); - meshes.forEach((mesh) => centroid.add(mesh.position)); - return centroid.divideScalar(meshes.length); - } /** * Clears the current atom selection, removes its highlight(s), and detaches the gizmo. * @param {boolean} forgetLastSelection - Also forget the remembered indices used to diff --git a/dist/mixins/marquee_selection.d.ts b/dist/mixins/marquee_selection.d.ts new file mode 100644 index 00000000..3ff54c7f --- /dev/null +++ b/dist/mixins/marquee_selection.d.ts @@ -0,0 +1,52 @@ +import * as THREE from "three"; +/** + * Mixin providing rubber-band marquee selection for InteractiveStructureEditorMixin: pressing + * down on empty space in edit mode and dragging past the click/drag threshold draws a + * screen-space rectangle and selects every atom whose projected position falls inside it on + * release. Composed alongside InteractiveStructureEditorMixin (which owns the pointer capture + * handlers that call into this mixin's updateMarqueeState_/finishMarqueeSelection_) and shares + * its `this` - selectedMeshes_, setSelectedAtomMeshes, collectSelectableAtoms, etc. all live on + * the base mixin. + */ +export declare const MarqueeSelectionMixin: (superclass: any) => { + new (config: any): { + [x: string]: any; + marqueeStartScreen_: { + x: number; + y: number; + } | null; + isMarqueeSelecting_: boolean; + marqueeOverlayElement_: HTMLDivElement | null; + marqueeModifierAdd_: boolean; + marqueeModifierToggle_: boolean; + /** + * Grows the marquee's screen-space rectangle as the pointer moves, activating it (and + * showing the overlay) only once the drag exceeds the same click-vs-drag threshold used + * for atom dragging, so a plain click on empty space still falls through to + * finishMarqueeSelection_'s deselect path instead of drawing a zero-size box. + */ + updateMarqueeState_(event: PointerEvent): void; + showMarqueeOverlay_(): void; + updateMarqueeOverlay_(currentX: number, currentY: number): void; + hideMarqueeOverlay_(): void; + /** + * Returns every atom whose projected screen position falls within the given + * (unordered) screen-space rectangle. Atoms behind the camera (or beyond the far + * plane) are excluded via the projected z check. + */ + getAtomsInScreenRect_(rect: { + left: number; + right: number; + top: number; + bottom: number; + }): THREE.Mesh[]; + /** + * Resolves a completed (or abandoned) marquee gesture. A release before crossing the + * drag threshold is just a plain click on empty space, so it falls through to the + * existing handlePointerDown click-to-deselect/select path rather than selecting an + * empty rectangle. + */ + finishMarqueeSelection_(event: PointerEvent): void; + }; + [x: string]: any; +}; diff --git a/dist/mixins/marquee_selection.js b/dist/mixins/marquee_selection.js new file mode 100644 index 00000000..6d4b6ea3 --- /dev/null +++ b/dist/mixins/marquee_selection.js @@ -0,0 +1,138 @@ +import { DRAG_THRESHOLD_PX } from "./interactive_editor_constants"; +const MARQUEE_FILL_COLOR = "rgba(84, 174, 255, 0.15)"; +const MARQUEE_BORDER_COLOR = "#54aeff"; +/** + * Mixin providing rubber-band marquee selection for InteractiveStructureEditorMixin: pressing + * down on empty space in edit mode and dragging past the click/drag threshold draws a + * screen-space rectangle and selects every atom whose projected position falls inside it on + * release. Composed alongside InteractiveStructureEditorMixin (which owns the pointer capture + * handlers that call into this mixin's updateMarqueeState_/finishMarqueeSelection_) and shares + * its `this` - selectedMeshes_, setSelectedAtomMeshes, collectSelectableAtoms, etc. all live on + * the base mixin. + */ +export const MarqueeSelectionMixin = (superclass) => class extends superclass { + constructor(config) { + super(config); + this.marqueeStartScreen_ = null; + this.isMarqueeSelecting_ = false; + this.marqueeOverlayElement_ = null; + this.marqueeModifierAdd_ = false; + this.marqueeModifierToggle_ = false; + } + /** + * Grows the marquee's screen-space rectangle as the pointer moves, activating it (and + * showing the overlay) only once the drag exceeds the same click-vs-drag threshold used + * for atom dragging, so a plain click on empty space still falls through to + * finishMarqueeSelection_'s deselect path instead of drawing a zero-size box. + */ + updateMarqueeState_(event) { + if (!this.marqueeStartScreen_) + return; + const distance = Math.sqrt((event.clientX - this.marqueeStartScreen_.x) ** 2 + + (event.clientY - this.marqueeStartScreen_.y) ** 2); + if (!this.isMarqueeSelecting_) { + if (distance < DRAG_THRESHOLD_PX) + return; + this.isMarqueeSelecting_ = true; + this.showMarqueeOverlay_(); + } + this.updateMarqueeOverlay_(event.clientX, event.clientY); + } + showMarqueeOverlay_() { + if (!this.marqueeOverlayElement_) { + const element = document.createElement("div"); + element.style.position = "absolute"; + element.style.border = `1px solid ${MARQUEE_BORDER_COLOR}`; + element.style.backgroundColor = MARQUEE_FILL_COLOR; + element.style.pointerEvents = "none"; + element.style.zIndex = "10"; + this.container.appendChild(element); + this.marqueeOverlayElement_ = element; + } + this.marqueeOverlayElement_.style.display = "block"; + if (this.marqueeStartScreen_) { + this.updateMarqueeOverlay_(this.marqueeStartScreen_.x, this.marqueeStartScreen_.y); + } + } + updateMarqueeOverlay_(currentX, currentY) { + if (!this.marqueeOverlayElement_ || !this.marqueeStartScreen_) + return; + const containerRect = this.container.getBoundingClientRect(); + const left = Math.min(this.marqueeStartScreen_.x, currentX) - containerRect.left; + const top = Math.min(this.marqueeStartScreen_.y, currentY) - containerRect.top; + const width = Math.abs(currentX - this.marqueeStartScreen_.x); + const height = Math.abs(currentY - this.marqueeStartScreen_.y); + this.marqueeOverlayElement_.style.left = `${left}px`; + this.marqueeOverlayElement_.style.top = `${top}px`; + this.marqueeOverlayElement_.style.width = `${width}px`; + this.marqueeOverlayElement_.style.height = `${height}px`; + } + hideMarqueeOverlay_() { + if (this.marqueeOverlayElement_) + this.marqueeOverlayElement_.style.display = "none"; + } + /** + * Returns every atom whose projected screen position falls within the given + * (unordered) screen-space rectangle. Atoms behind the camera (or beyond the far + * plane) are excluded via the projected z check. + */ + getAtomsInScreenRect_(rect) { + const boundingRectangle = this.renderer.domElement.getBoundingClientRect(); + return this.collectSelectableAtoms().filter((atom) => { + const projected = atom.position.clone().project(this.camera); + if (projected.z < -1 || projected.z > 1) + return false; + const screenX = boundingRectangle.left + ((projected.x + 1) / 2) * boundingRectangle.width; + const screenY = boundingRectangle.top + ((1 - projected.y) / 2) * boundingRectangle.height; + return (screenX >= rect.left && + screenX <= rect.right && + screenY >= rect.top && + screenY <= rect.bottom); + }); + } + /** + * Resolves a completed (or abandoned) marquee gesture. A release before crossing the + * drag threshold is just a plain click on empty space, so it falls through to the + * existing handlePointerDown click-to-deselect/select path rather than selecting an + * empty rectangle. + */ + finishMarqueeSelection_(event) { + const wasSelecting = this.isMarqueeSelecting_; + const startScreen = this.marqueeStartScreen_; + const addModifier = this.marqueeModifierAdd_; + const toggleModifier = this.marqueeModifierToggle_; + this.hideMarqueeOverlay_(); + this.marqueeStartScreen_ = null; + this.isMarqueeSelecting_ = false; + if (!wasSelecting || !startScreen) { + this.handlePointerDown(event); + return; + } + const rect = { + left: Math.min(startScreen.x, event.clientX), + right: Math.max(startScreen.x, event.clientX), + top: Math.min(startScreen.y, event.clientY), + bottom: Math.max(startScreen.y, event.clientY), + }; + const hits = this.getAtomsInScreenRect_(rect); + let nextSelection; + if (toggleModifier) { + const hitSet = new Set(hits); + const kept = this.selectedMeshes_.filter((mesh) => !hitSet.has(mesh)); + const added = hits.filter((mesh) => !this.selectedMeshes_.includes(mesh)); + nextSelection = [...kept, ...added]; + } + else if (addModifier) { + const added = hits.filter((mesh) => !this.selectedMeshes_.includes(mesh)); + nextSelection = [...this.selectedMeshes_, ...added]; + } + else { + nextSelection = hits; + } + this.setSelectedAtomMeshes(nextSelection); + if (this.settings.onSelectionChanged) { + this.settings.onSelectionChanged(nextSelection.map((mesh) => mesh.userData.atomicIndex)); + } + this.render(); + } +}; diff --git a/dist/wave.js b/dist/wave.js index 3f312de6..77d7a0b0 100644 --- a/dist/wave.js +++ b/dist/wave.js @@ -8,9 +8,11 @@ import { BondsMixin } from "./mixins/bonds"; import { BoundaryMixin } from "./mixins/boundary"; import { CellMixin } from "./mixins/cell"; import { ControlsMixin } from "./mixins/controls"; +import { GroupTransformMixin } from "./mixins/group_transform"; import { ImageMixin } from "./mixins/image"; import { InteractiveStructureEditorMixin } from "./mixins/interactive_structure_editor"; import { AllLabelsMixin } from "./mixins/labels/all"; +import { MarqueeSelectionMixin } from "./mixins/marquee_selection"; import { AllMeasurementsMixin } from "./mixins/measurements/all"; import { RepetitionMixin } from "./mixins/repetition"; import SETTINGS from "./settings"; @@ -209,7 +211,7 @@ class WaveBase { /** * Wave draws atoms as spheres according to the material geometry passed. */ -export class Wave extends mix(WaveBase).with(AtomsMixin, BondsMixin, CellMixin, RepetitionMixin, ControlsMixin, BoundaryMixin, AllLabelsMixin, AllMeasurementsMixin, ImageMixin, InteractiveStructureEditorMixin) { +export class Wave extends mix(WaveBase).with(AtomsMixin, BondsMixin, CellMixin, RepetitionMixin, ControlsMixin, BoundaryMixin, AllLabelsMixin, AllMeasurementsMixin, ImageMixin, MarqueeSelectionMixin, GroupTransformMixin, InteractiveStructureEditorMixin) { /** * * @param {Object} config diff --git a/plan/interactive-editor-spec-plan.md b/plan/interactive-editor-spec-plan.md index 554b208b..38618b83 100644 --- a/plan/interactive-editor-spec-plan.md +++ b/plan/interactive-editor-spec-plan.md @@ -27,7 +27,7 @@ Prepared and reviewed via `agent-code-review-tb` (`/Users/timur/Code/tb-review-a **Investigated and resolved without a code change — the `three` fork-to-stock swap** (`"three": "npm:@exabyte-io/three@2023.8.23-0"` → `"^0.140.2"`, undocumented in the diff): compared `github.com/mat3ra/three.js` (the fork, a real fork of `mrdoob/three.js`) at its pinned commit against upstream. It patches `examples/jsm/controls/TransformControls.js` in 4 places: (a) `attach()` special-cases the fork's own `editor/js/objects/MultipleSelectionGroup.js` class — dead for this codebase, which never imports or constructs that class (its own multi-select uses a plain `THREE.Object3D` pivot); (b) `setMode()` restores visibility if hidden — a minor edge case (mode-switching with nothing selected) not exercised by this app's toolbar gating; (c) whitespace/comment-only changes in `TransformControlsGizmo`; (d) `TransformControlsPlane`'s rotate-mode plane computation has `_dirVector.set(0, 0, 0)` commented out, which (given `_dirVector` is a module-level reused `Vector3`) means the fork's rotate-plane could inherit stale state from a prior translate/scale operation instead of always resetting to face the camera — this looks like fragile/unintentional behavior rather than a deliberate fix, and it's exactly the mechanism this round's extensive rotate testing (34+ tests, unit + live-browser, hand-verified quaternion math against stock three's *always-camera-facing* behavior) already exercises and confirms works correctly. Conclusion: the swap is safe for this codebase's actual usage; `MultipleSelectionGroup` in particular answers spec §11's own old-editor-parity question (the old editor's rubber-band multi-select lived in the fork's `editor/js/`, not in stock three.js or in a wave.js source file). -**Deferred, not fixed this round** (tracked here rather than lost; `onEditCommit` and the whole-basis unit-conversion round-trip listed in earlier drafts of this note are resolved — see "Chained PR stack" below): splitting `interactive_structure_editor.ts` (TB-ARCH-2 — still the largest file in `src/mixins/` by a wide margin) into `MarqueeSelectionMixin`/`GroupTransformMixin` (chained PR 3, not started); marquee-select sampling its Shift/Ctrl/Cmd modifiers at drag-*start* while a plain click samples at release (a real but rare-to-hit inconsistency - deliberately not rushed in this pass); a handful of nits (the C++/DFT-flavored `AGENTS.md` at repo root, `.gitignore`'s bare `plan` entry going stale the moment a new file lands in `plan/`, three undocumented marquee-overlay helper methods, orphaned `handleSetMaterial`, and the ~400 remaining lines of `#threejs-editor`-scoped dead CSS in `main.css` beyond the CodeMirror-specific rules already removed). +**Deferred, not fixed this round** (tracked here rather than lost; `onEditCommit`, the whole-basis unit-conversion round-trip, and the `interactive_structure_editor.ts` mixin split listed in earlier drafts of this note are all resolved — see "Chained PR stack" below): marquee-select sampling its Shift/Ctrl/Cmd modifiers at drag-*start* while a plain click samples at release (a real but rare-to-hit inconsistency - deliberately not rushed in this pass); a handful of nits (the C++/DFT-flavored `AGENTS.md` at repo root, `.gitignore`'s bare `plan` entry going stale the moment a new file lands in `plan/`, three undocumented marquee-overlay helper methods, orphaned `handleSetMaterial`, and the ~400 remaining lines of `#threejs-editor`-scoped dead CSS in `main.css` beyond the CodeMirror-specific rules already removed). ## Chained PR stack (2026-08-01, post-review) @@ -35,7 +35,11 @@ Beyond the review's own findings, three further improvements were identified and 1. ✅ **`onEditCommit` host callback (spec §6.2)** — PR [#207](https://github.com/mat3ra/wave.js/pull/207). Was listed alongside `onSelectionChanged`/`onEditModeChanged` as P1 scope but only the latter two had actually shipped. `commitMovedAtom_`/`commitMovedAtoms_`/`addAtom`/`removeSelectedAtom`/`removeSelectedAtoms_`/`cloneSelectedAtoms` now tag every commit with a `source` (`drag · gizmo · coordinate-input · element-input · add · remove · clone · undo · redo` — the last two added to the spec's original 7-value enum for this round's type-to-change and clone-atom features), forwarded through `onStructureModified(material, source)` to `ThreeDEditor.jsx`'s `onEditCommit(material, {source})`. 12 new tests. 2. ✅ **Scope basis unit conversion to the touched atom(s) (spec §5.2)** — this branch. `commitMovedAtoms_`/`addAtom`/`cloneSelectedAtoms` placed a touched atom's coordinate by flipping the *whole* basis to Cartesian and back (`basis.toCartesian()`/`toCrystal()`) around the mutation — `Basis.toCartesian`/`toCrystal` round-trip **every** atom's coordinate via `mapArrayInPlace`, not just the touched one, so §5.2's "untouched atoms preserved bit-for-bit because they are never re-derived" claim didn't fully hold. Empirically (verified directly against `@mat3ra/made`, both the shipped test fixture's lattice and several deliberately ill-conditioned ones) this was invisible in practice: `Basis` rounds every coordinate to `Cell.roundPrecision` (9 decimals) on serialize, which absorbs a single round-trip's float noise (~1e-15) for any reasonably-conditioned cell — but the correctness relied on that rounding as an accidental safety net, and did needless O(n) matrix work on every single-atom edit. Fixed to convert only the touched point(s) directly via `basis.cell.convertPointToCrystal`/`convertPointToCartesian`, dropping the `wasCartesian`/`toCartesian()`/`toCrystal()` wrapper at all three call sites entirely. Regression tests (`interactive_structure_editor.js`, describe block "Basis unit-conversion scoped to touched atoms (spec §5.2)") spy on the ephemeral basis instance `applyBasisDelta_` builds per commit — scoped to that instance specifically, not the shared `Basis` prototype, since the wave legitimately calls `toCartesian`/`toCrystal` elsewhere for unrelated reasons (`setStructure`'s always-Cartesian rendering-basis sync; Made's `repeat()` tool computing periodic-image positions during `rebuildScene`) that a prototype-wide spy would wrongly flag — plus value-correctness checks (untouched atoms' coordinates exactly unchanged, a moved atom's committed crystal coordinate round-trips to its exact dragged Cartesian position). All 3 "does not round-trip" tests confirmed failing against the pre-fix code and passing after, via a temporary `git stash` of just the source change. -3. ⬜ **Split `interactive_structure_editor.ts` into focused mixins** (TB-ARCH-2) — not started. `MarqueeSelectionMixin`/`GroupTransformMixin` extracted from the monolith, which remains the largest file in `src/mixins/` by a wide margin. +3. ✅ **Split `interactive_structure_editor.ts` into focused mixins** (TB-ARCH-2) — this branch. The monolith was 1460 lines; marquee rubber-band selection (`updateMarqueeState_`, `showMarqueeOverlay_`/`updateMarqueeOverlay_`/`hideMarqueeOverlay_`, `getAtomsInScreenRect_`, `finishMarqueeSelection_` - self-contained, verbatim-movable) now lives in a new `MarqueeSelectionMixin` (`src/mixins/marquee_selection.ts`), and group-transform pivot/centroid math (`attachPivotToSelection_`, `computeCentroid_`) plus four newly-extracted helpers now live in a new `GroupTransformMixin` (`src/mixins/group_transform.ts`): `handleGroupPivotChange_` and `commitGroupGizmoDrag_` (the group-specific halves of the TransformControls `change`/`mouseUp` listeners, which `initializeEditor()` now calls into instead of inlining), `captureGroupDragStartPositions_` (also de-duplicates an identical inline snapshot previously repeated in `beginAtomDrag_`), and `applyGroupDragDelta_` (the group half of the direct-drag pointer-move handler). `DRAG_THRESHOLD_PX`, needed by both the base mixin's atom-drag and the new marquee mixin, moved to a small shared `interactive_editor_constants.ts`. Both new mixins are composed into `Wave` in `wave.js` immediately before `InteractiveStructureEditorMixin` (whose constructor still runs last and calls `initializeEditor()`/`initializeSelectionRaycaster()`, by which point the two new mixins' constructors have already set their own state fields to defaults). Base file: 1460 → 1211 lines. + + `beginAtomDrag_`/`endAtomDrag_`/`cancelAtomDrag_` (the direct-drag lifecycle) deliberately stay in the base mixin, unsplit: they're a genuinely unified state machine covering all 4 drag shapes (single/group × direct/gizmo) at once - see `cancelAtomDrag_`'s own comment - and this round's Esc-cancel regression tests are concentrated exactly there, so forcing a single/group split would trade a real, hard-won correctness surface for a readability preference. Scoping the split to what's cleanly separable (marquee; the group-only halves of the transform listeners) was the deliberate boundary. + + Pure refactor, verified for behavioral equivalence three ways: (1) all 181 pre-existing tests pass unchanged - no test file needed a single edit; (2) a real TypeScript narrowing gap surfaced by the split itself (`group_transform.ts`'s fields no longer share a class body with `transformControls_`/`transformDragStartPosition_`, so `tsc` couldn't narrow `selectionPivot_`'s nullability through a compound guard referencing them) was caught by `tsc --noEmit` and fixed by capturing `selectionPivot_` in a local before the guard, rather than suppressed; (3) live-browser smoke test against a real (non-jsdom, non-mocked) WebGL `Wave` instance - marquee-select, group direct-drag, and gizmo group-rotate all independently confirmed correct (rectangle selects both atoms; both atoms move by an identical delta; rotation preserves the centroid and resets the pivot's quaternion post-commit). ## What's outstanding: P2, gated on open decisions (spec §9) diff --git a/src/mixins/group_transform.ts b/src/mixins/group_transform.ts new file mode 100644 index 00000000..2c05b54b --- /dev/null +++ b/src/mixins/group_transform.ts @@ -0,0 +1,159 @@ +import * as THREE from "three"; + +/** + * Mixin providing rigid group transforms (translate/rotate about a shared centroid pivot) for + * InteractiveStructureEditorMixin's multi-atom selection: a 2+ atom selection drags or rotates + * together as one commit, matching the old editor's MultipleSelectionControls pivot-group + * behavior (decision D-4). Composed alongside InteractiveStructureEditorMixin, which owns the + * TransformControls lifecycle listeners and the direct-drag pointer handlers that call into this + * mixin's helpers, and shares its `this` - selectedMeshes_, transformControls_, + * commitMovedAtoms_, etc. all live on the base mixin. + */ +export const GroupTransformMixin = (superclass: any) => + class extends superclass { + selectionPivot_: THREE.Object3D | null; + + groupDragStartPositions_: Map | null; + + isDraggingGroup_: boolean; + + constructor(config: any) { + super(config); + + this.selectionPivot_ = null; + this.groupDragStartPositions_ = null; + this.isDraggingGroup_ = false; + } + + /** + * Repositions the group-transform pivot at the current selection's centroid and attaches + * the gizmo to it. + */ + attachPivotToSelection_(): void { + if (!this.selectionPivot_ || this.selectedMeshes_.length === 0) return; + this.selectionPivot_.position.copy(this.computeCentroid_(this.selectedMeshes_)); + // A fresh attach always starts unrotated, even if a previous group drag left the + // pivot's quaternion non-identity for any reason (the mouseUp handler already resets + // it after every rotate commit - this is a defensive backstop, not the primary path). + this.selectionPivot_.quaternion.identity(); + if (this.transformControls_) this.transformControls_.attach(this.selectionPivot_); + } + + /** + * Shared by attachPivotToSelection_ and the group direct-drag move handler, which must + * keep the pivot (and therefore the visible gizmo) tracking the group's centroid as it + * moves - without this, the gizmo would stay frozen at the pre-drag centroid for the + * whole gesture and only jump to the correct spot once the post-commit rebuild reattaches + * it. + */ + // eslint-disable-next-line class-methods-use-this + computeCentroid_(meshes: THREE.Mesh[]): THREE.Vector3 { + const centroid = new THREE.Vector3(); + meshes.forEach((mesh: THREE.Mesh) => centroid.add(mesh.position)); + return centroid.divideScalar(meshes.length); + } + + /** + * Snapshots every selected atom's current position, keyed by mesh, so a group gizmo or + * direct drag can compute each frame's delta against a fixed start rather than the + * previous frame (which would accumulate rounding error) and so Esc can revert cleanly. + */ + captureGroupDragStartPositions_(): void { + this.groupDragStartPositions_ = new Map( + this.selectedMeshes_.map((mesh: THREE.Mesh) => [mesh, mesh.position.clone()]), + ); + } + + /** + * Called on every TransformControls "change" event while the group pivot is the object + * being dragged: propagates the pivot's live translate/rotate delta to every selected + * atom so the group moves rigidly together during the gesture, not just once on commit. + * Translate mode applies the pivot's position delta to each atom; rotate mode leaves the + * pivot's position fixed at the centroid and instead rotates each atom's offset from it + * by the pivot's quaternion, so the group rotates rigidly about its shared centroid. + */ + handleGroupPivotChange_(): void { + // Captured as a local so its non-null-ness (checked right below) narrows reliably - + // transformControls_/transformDragStartPosition_ are declared on a sibling mixin + // class (InteractiveStructureEditorMixin), not this one, so TS sees them as `any` + // here and won't propagate narrowing from a compound guard into selectionPivot_. + const pivot = this.selectionPivot_; + if ( + !pivot || + !this.transformControls_?.dragging || + this.transformControls_.object !== pivot || + !this.groupDragStartPositions_ || + !this.transformDragStartPosition_ + ) { + return; + } + if (this.transformControls_.mode === "rotate") { + const pivotCenter = this.transformDragStartPosition_; + const rotation = pivot.quaternion; + this.selectedMeshes_.forEach((mesh: THREE.Mesh) => { + const start = this.groupDragStartPositions_?.get(mesh); + if (!start) return; + const offset = start.clone().sub(pivotCenter).applyQuaternion(rotation); + mesh.position.copy(pivotCenter.clone().add(offset)); + }); + } else { + const delta = pivot.position.clone().sub(this.transformDragStartPosition_); + this.selectedMeshes_.forEach((mesh: THREE.Mesh) => { + const start = this.groupDragStartPositions_?.get(mesh); + if (start) mesh.position.copy(start.clone().add(delta)); + }); + } + this.syncHighlightPoolTo_(this.selectedMeshes_); + } + + /** + * Commits a completed group gizmo drag (translate or rotate) as a single delta/commit - + * called from the TransformControls "mouseUp" listener once it's determined the pivot + * (not a lone atom) was the dragged object and it actually moved/rotated. + */ + commitGroupGizmoDrag_(wasRotate: boolean): void { + this.commitMovedAtoms_( + this.selectedMeshes_.map((mesh: THREE.Mesh) => ({ + atomicIndex: mesh.userData.atomicIndex, + position: mesh.position.clone(), + })), + "gizmo", + ); + if (wasRotate) { + // The pivot's rotation is relative to each drag, not cumulative across drags - + // the atoms' new positions already encode the rotation, so reset it to identity + // for the next attach/drag. Optional-chained defensively: this is only ever + // called once the caller has confirmed selectionPivot_ was the dragged object, + // but that guard lives in the caller, not in this method's own type narrowing. + this.selectionPivot_?.quaternion.identity(); + } + } + + /** + * If a group direct-drag (not the gizmo) is in progress, moves every selected atom by + * the same delta as the pressed atom and keeps the pivot tracking the live centroid. + * Returns whether it applied - the caller falls back to moving just the pending atom + * when this returns false, matching a lone-atom (or gizmo) drag. + */ + applyGroupDragDelta_(newPosition: THREE.Vector3): boolean { + if ( + !this.isDraggingGroup_ || + !this.groupDragStartPositions_ || + !this.pendingDragAtom_ + ) { + return false; + } + const draggedStart = this.groupDragStartPositions_.get(this.pendingDragAtom_); + if (draggedStart) { + const delta = newPosition.clone().sub(draggedStart); + this.selectedMeshes_.forEach((mesh: THREE.Mesh) => { + const start = this.groupDragStartPositions_?.get(mesh); + if (start) mesh.position.copy(start.clone().add(delta)); + }); + if (this.selectionPivot_) { + this.selectionPivot_.position.copy(this.computeCentroid_(this.selectedMeshes_)); + } + } + return true; + } + }; diff --git a/src/mixins/interactive_editor_constants.ts b/src/mixins/interactive_editor_constants.ts new file mode 100644 index 00000000..fb68a29b --- /dev/null +++ b/src/mixins/interactive_editor_constants.ts @@ -0,0 +1,7 @@ +/** + * Pixel distance a pointer must travel before a press-and-move gesture commits to a drag + * (atom drag or marquee rubber-band) instead of registering as a plain click on release. + * Shared between InteractiveStructureEditorMixin (atom drag) and MarqueeSelectionMixin + * (marquee activation), which otherwise have no dependency on each other. + */ +export const DRAG_THRESHOLD_PX = 5; diff --git a/src/mixins/interactive_structure_editor.ts b/src/mixins/interactive_structure_editor.ts index 82877557..32202d03 100644 --- a/src/mixins/interactive_structure_editor.ts +++ b/src/mixins/interactive_structure_editor.ts @@ -1,15 +1,14 @@ import * as THREE from "three"; import { TransformControls } from "three/examples/jsm/controls/TransformControls"; +import { DRAG_THRESHOLD_PX } from "./interactive_editor_constants"; + type Coordinate3D = [number, number, number]; const HOVER_HIGHLIGHT_COLOR = 0x54aeff; const SELECTION_HIGHLIGHT_COLOR = 0x0969da; const HIGHLIGHT_SCALE_FACTOR = 1.25; -const DRAG_THRESHOLD_PX = 5; const DRAG_COMMIT_EPSILON = 1e-6; -const MARQUEE_FILL_COLOR = "rgba(84, 174, 255, 0.15)"; -const MARQUEE_BORDER_COLOR = "#54aeff"; /** * Mixin providing interactive structure editing capabilities inside the Wave visualizer. @@ -37,8 +36,6 @@ export const InteractiveStructureEditorMixin = (superclass: any) => hoverHighlightMesh_: THREE.Mesh | null; - selectionPivot_: THREE.Object3D | null; - isEditModeEnabled_: boolean; pointerDownPosition_: { x: number; y: number } | null; @@ -47,10 +44,6 @@ export const InteractiveStructureEditorMixin = (superclass: any) => isDraggingAtom_: boolean; - isDraggingGroup_: boolean; - - groupDragStartPositions_: Map | null; - dragPlane_: THREE.Plane | null; dragOffset_: THREE.Vector3 | null; @@ -65,16 +58,6 @@ export const InteractiveStructureEditorMixin = (superclass: any) => lastSelectedAtomicIndices_: number[] | null; - marqueeStartScreen_: { x: number; y: number } | null; - - isMarqueeSelecting_: boolean; - - marqueeOverlayElement_: HTMLDivElement | null; - - marqueeModifierAdd_: boolean; - - marqueeModifierToggle_: boolean; - handlePointerDownCapture_: ((event: PointerEvent) => void) | null; handlePointerMoveCapture_: ((event: PointerEvent) => void) | null; @@ -98,13 +81,10 @@ export const InteractiveStructureEditorMixin = (superclass: any) => this.hoveredMesh_ = null; this.selectionHighlightPool_ = []; this.hoverHighlightMesh_ = null; - this.selectionPivot_ = null; this.isEditModeEnabled_ = false; this.pointerDownPosition_ = null; this.pendingDragAtom_ = null; this.isDraggingAtom_ = false; - this.isDraggingGroup_ = false; - this.groupDragStartPositions_ = null; this.dragPlane_ = null; this.dragOffset_ = null; this.dragStartPosition_ = null; @@ -112,11 +92,6 @@ export const InteractiveStructureEditorMixin = (superclass: any) => this.orbitControlsEnabledBeforeDrag_ = true; this.orbitControlsDefaultMouseButtons_ = null; this.lastSelectedAtomicIndices_ = null; - this.marqueeStartScreen_ = null; - this.isMarqueeSelecting_ = false; - this.marqueeOverlayElement_ = null; - this.marqueeModifierAdd_ = false; - this.marqueeModifierToggle_ = false; this.handlePointerDownCapture_ = null; this.handlePointerMoveCapture_ = null; this.handlePointerUpCapture_ = null; @@ -155,40 +130,11 @@ export const InteractiveStructureEditorMixin = (superclass: any) => this.scene.add(this.hoverHighlightMesh_); // Rerender the viewport on every translation/rotation frame update; while dragging the - // group pivot, also propagate its live delta to every selected atom so they move - // rigidly together during the gizmo drag, not just once on commit. Translate mode - // moves the pivot's position (apply the same position delta to each atom); rotate - // mode leaves the pivot's position fixed at the centroid and instead rotates its - // quaternion (rotate each atom's offset-from-centroid by that same quaternion, so the - // group rotates rigidly about its shared centroid - the old editor's pivot-group - // behavior, decision D-4's "group rotate" half). + // group pivot, handleGroupPivotChange_ (GroupTransformMixin) also propagates its live + // delta to every selected atom so they move rigidly together during the gizmo drag, + // not just once on commit (decision D-4's "group rotate" half). this.transformControls_.addEventListener("change", () => { - if ( - this.transformControls_?.dragging && - this.transformControls_.object === this.selectionPivot_ && - this.groupDragStartPositions_ && - this.transformDragStartPosition_ - ) { - if (this.transformControls_.mode === "rotate") { - const pivotCenter = this.transformDragStartPosition_; - const rotation = this.selectionPivot_.quaternion; - this.selectedMeshes_.forEach((mesh: THREE.Mesh) => { - const start = this.groupDragStartPositions_?.get(mesh); - if (!start) return; - const offset = start.clone().sub(pivotCenter).applyQuaternion(rotation); - mesh.position.copy(pivotCenter.clone().add(offset)); - }); - } else { - const delta = this.selectionPivot_.position - .clone() - .sub(this.transformDragStartPosition_); - this.selectedMeshes_.forEach((mesh: THREE.Mesh) => { - const start = this.groupDragStartPositions_?.get(mesh); - if (start) mesh.position.copy(start.clone().add(delta)); - }); - } - this.syncHighlightPoolTo_(this.selectedMeshes_); - } + this.handleGroupPivotChange_(); this.render(); }); @@ -205,12 +151,7 @@ export const InteractiveStructureEditorMixin = (superclass: any) => this.transformDragStartQuaternion_ = this.transformControls_?.object?.quaternion.clone() ?? null; if (this.transformControls_?.object === this.selectionPivot_) { - this.groupDragStartPositions_ = new Map( - this.selectedMeshes_.map((mesh: THREE.Mesh) => [ - mesh, - mesh.position.clone(), - ]), - ); + this.captureGroupDragStartPositions_(); } } else { if (this.orbitControls) { @@ -241,19 +182,7 @@ export const InteractiveStructureEditorMixin = (superclass: any) => if ((hasMoved || hasRotated) && draggedObject) { if (draggedObject === this.selectionPivot_) { - this.commitMovedAtoms_( - this.selectedMeshes_.map((mesh: THREE.Mesh) => ({ - atomicIndex: mesh.userData.atomicIndex, - position: mesh.position.clone(), - })), - "gizmo", - ); - if (wasRotate) { - // The pivot's rotation is relative to each drag, not cumulative - // across drags - the atoms' new positions already encode the - // rotation, so reset it to identity for the next attach/drag. - this.selectionPivot_.quaternion.identity(); - } + this.commitGroupGizmoDrag_(wasRotate); } else { this.commitMovedAtom_( draggedObject.userData.atomicIndex, @@ -391,28 +320,10 @@ export const InteractiveStructureEditorMixin = (superclass: any) => if (!point) return; const newPosition = point.add(this.dragOffset_); - if ( - this.isDraggingGroup_ && - this.groupDragStartPositions_ && - this.pendingDragAtom_ - ) { - const draggedStart = this.groupDragStartPositions_.get(this.pendingDragAtom_); - if (draggedStart) { - const delta = newPosition.clone().sub(draggedStart); - this.selectedMeshes_.forEach((mesh: THREE.Mesh) => { - const start = this.groupDragStartPositions_?.get(mesh); - if (start) mesh.position.copy(start.clone().add(delta)); - }); - // Keep the gizmo (attached to the pivot, not to any one dragged mesh) - // visually tracking the group instead of staying frozen at the pre-drag - // centroid for the whole gesture. - if (this.selectionPivot_) { - this.selectionPivot_.position.copy( - this.computeCentroid_(this.selectedMeshes_), - ); - } - } - } else { + // applyGroupDragDelta_ (GroupTransformMixin) also keeps the gizmo (attached to + // the pivot, not to any one dragged mesh) visually tracking the group instead of + // staying frozen at the pre-drag centroid for the whole gesture. + if (!this.applyGroupDragDelta_(newPosition)) { this.pendingDragAtom_.position.copy(newPosition); } this.syncHighlightPoolTo_(this.selectedMeshes_); @@ -516,141 +427,11 @@ export const InteractiveStructureEditorMixin = (superclass: any) => this.render(); } - // ---- Marquee (rubber-band) selection -------------------------------------------- - - /** - * Grows the marquee's screen-space rectangle as the pointer moves, activating it (and - * showing the overlay) only once the drag exceeds the same click-vs-drag threshold used - * for atom dragging, so a plain click on empty space still falls through to - * finishMarqueeSelection_'s deselect path instead of drawing a zero-size box. - */ - updateMarqueeState_(event: PointerEvent): void { - if (!this.marqueeStartScreen_) return; - const distance = Math.sqrt( - (event.clientX - this.marqueeStartScreen_.x) ** 2 + - (event.clientY - this.marqueeStartScreen_.y) ** 2, - ); - if (!this.isMarqueeSelecting_) { - if (distance < DRAG_THRESHOLD_PX) return; - this.isMarqueeSelecting_ = true; - this.showMarqueeOverlay_(); - } - this.updateMarqueeOverlay_(event.clientX, event.clientY); - } - - showMarqueeOverlay_(): void { - if (!this.marqueeOverlayElement_) { - const element = document.createElement("div"); - element.style.position = "absolute"; - element.style.border = `1px solid ${MARQUEE_BORDER_COLOR}`; - element.style.backgroundColor = MARQUEE_FILL_COLOR; - element.style.pointerEvents = "none"; - element.style.zIndex = "10"; - this.container.appendChild(element); - this.marqueeOverlayElement_ = element; - } - this.marqueeOverlayElement_.style.display = "block"; - if (this.marqueeStartScreen_) { - this.updateMarqueeOverlay_(this.marqueeStartScreen_.x, this.marqueeStartScreen_.y); - } - } - - updateMarqueeOverlay_(currentX: number, currentY: number): void { - if (!this.marqueeOverlayElement_ || !this.marqueeStartScreen_) return; - const containerRect = this.container.getBoundingClientRect(); - const left = Math.min(this.marqueeStartScreen_.x, currentX) - containerRect.left; - const top = Math.min(this.marqueeStartScreen_.y, currentY) - containerRect.top; - const width = Math.abs(currentX - this.marqueeStartScreen_.x); - const height = Math.abs(currentY - this.marqueeStartScreen_.y); - this.marqueeOverlayElement_.style.left = `${left}px`; - this.marqueeOverlayElement_.style.top = `${top}px`; - this.marqueeOverlayElement_.style.width = `${width}px`; - this.marqueeOverlayElement_.style.height = `${height}px`; - } - - hideMarqueeOverlay_(): void { - if (this.marqueeOverlayElement_) this.marqueeOverlayElement_.style.display = "none"; - } - - /** - * Returns every atom whose projected screen position falls within the given - * (unordered) screen-space rectangle. Atoms behind the camera (or beyond the far - * plane) are excluded via the projected z check. - */ - getAtomsInScreenRect_(rect: { - left: number; - right: number; - top: number; - bottom: number; - }): THREE.Mesh[] { - const boundingRectangle = this.renderer.domElement.getBoundingClientRect(); - return this.collectSelectableAtoms().filter((atom: THREE.Mesh) => { - const projected = atom.position.clone().project(this.camera); - if (projected.z < -1 || projected.z > 1) return false; - const screenX = - boundingRectangle.left + ((projected.x + 1) / 2) * boundingRectangle.width; - const screenY = - boundingRectangle.top + ((1 - projected.y) / 2) * boundingRectangle.height; - return ( - screenX >= rect.left && - screenX <= rect.right && - screenY >= rect.top && - screenY <= rect.bottom - ); - }); - } - - /** - * Resolves a completed (or abandoned) marquee gesture. A release before crossing the - * drag threshold is just a plain click on empty space, so it falls through to the - * existing handlePointerDown click-to-deselect/select path rather than selecting an - * empty rectangle. - */ - finishMarqueeSelection_(event: PointerEvent): void { - const wasSelecting = this.isMarqueeSelecting_; - const startScreen = this.marqueeStartScreen_; - const addModifier = this.marqueeModifierAdd_; - const toggleModifier = this.marqueeModifierToggle_; - this.hideMarqueeOverlay_(); - this.marqueeStartScreen_ = null; - this.isMarqueeSelecting_ = false; - - if (!wasSelecting || !startScreen) { - this.handlePointerDown(event); - return; - } - - const rect = { - left: Math.min(startScreen.x, event.clientX), - right: Math.max(startScreen.x, event.clientX), - top: Math.min(startScreen.y, event.clientY), - bottom: Math.max(startScreen.y, event.clientY), - }; - const hits = this.getAtomsInScreenRect_(rect); - - let nextSelection: THREE.Mesh[]; - if (toggleModifier) { - const hitSet = new Set(hits); - const kept = this.selectedMeshes_.filter((mesh: THREE.Mesh) => !hitSet.has(mesh)); - const added = hits.filter((mesh) => !this.selectedMeshes_.includes(mesh)); - nextSelection = [...kept, ...added]; - } else if (addModifier) { - const added = hits.filter((mesh) => !this.selectedMeshes_.includes(mesh)); - nextSelection = [...this.selectedMeshes_, ...added]; - } else { - nextSelection = hits; - } - - this.setSelectedAtomMeshes(nextSelection); - if (this.settings.onSelectionChanged) { - this.settings.onSelectionChanged( - nextSelection.map((mesh) => mesh.userData.atomicIndex), - ); - } - this.render(); - } - // ---- Direct atom drag (single or, for a multi-selected atom, the whole group) ----- + // + // Marquee (rubber-band) selection lives in MarqueeSelectionMixin (updateMarqueeState_, + // showMarqueeOverlay_/updateMarqueeOverlay_/hideMarqueeOverlay_, getAtomsInScreenRect_, + // finishMarqueeSelection_), called from the pointer capture handlers below. /** * Selects the pending atom and sets up the camera-facing drag plane through its current @@ -668,9 +449,7 @@ export const InteractiveStructureEditorMixin = (superclass: any) => this.selectedMeshes_.length > 1 && this.selectedMeshes_.includes(this.pendingDragAtom_); if (this.isDraggingGroup_) { - this.groupDragStartPositions_ = new Map( - this.selectedMeshes_.map((mesh: THREE.Mesh) => [mesh, mesh.position.clone()]), - ); + this.captureGroupDragStartPositions_(); } else { this.groupDragStartPositions_ = null; this.setSelectedAtomMesh(this.pendingDragAtom_); @@ -938,34 +717,6 @@ export const InteractiveStructureEditorMixin = (superclass: any) => } } - /** - * Repositions the group-transform pivot at the current selection's centroid and attaches - * the gizmo to it. - */ - attachPivotToSelection_(): void { - if (!this.selectionPivot_ || this.selectedMeshes_.length === 0) return; - this.selectionPivot_.position.copy(this.computeCentroid_(this.selectedMeshes_)); - // A fresh attach always starts unrotated, even if a previous group drag left the - // pivot's quaternion non-identity for any reason (the mouseUp handler already resets - // it after every rotate commit - this is a defensive backstop, not the primary path). - this.selectionPivot_.quaternion.identity(); - if (this.transformControls_) this.transformControls_.attach(this.selectionPivot_); - } - - /** - * Shared by attachPivotToSelection_ and the group direct-drag move handler, which must - * keep the pivot (and therefore the visible gizmo) tracking the group's centroid as it - * moves - without this, the gizmo would stay frozen at the pre-drag centroid for the - * whole gesture and only jump to the correct spot once the post-commit rebuild reattaches - * it. - */ - // eslint-disable-next-line class-methods-use-this - computeCentroid_(meshes: THREE.Mesh[]): THREE.Vector3 { - const centroid = new THREE.Vector3(); - meshes.forEach((mesh: THREE.Mesh) => centroid.add(mesh.position)); - return centroid.divideScalar(meshes.length); - } - /** * Clears the current atom selection, removes its highlight(s), and detaches the gizmo. * @param {boolean} forgetLastSelection - Also forget the remembered indices used to diff --git a/src/mixins/marquee_selection.ts b/src/mixins/marquee_selection.ts new file mode 100644 index 00000000..cc8b4642 --- /dev/null +++ b/src/mixins/marquee_selection.ts @@ -0,0 +1,170 @@ +import * as THREE from "three"; + +import { DRAG_THRESHOLD_PX } from "./interactive_editor_constants"; + +const MARQUEE_FILL_COLOR = "rgba(84, 174, 255, 0.15)"; +const MARQUEE_BORDER_COLOR = "#54aeff"; + +/** + * Mixin providing rubber-band marquee selection for InteractiveStructureEditorMixin: pressing + * down on empty space in edit mode and dragging past the click/drag threshold draws a + * screen-space rectangle and selects every atom whose projected position falls inside it on + * release. Composed alongside InteractiveStructureEditorMixin (which owns the pointer capture + * handlers that call into this mixin's updateMarqueeState_/finishMarqueeSelection_) and shares + * its `this` - selectedMeshes_, setSelectedAtomMeshes, collectSelectableAtoms, etc. all live on + * the base mixin. + */ +export const MarqueeSelectionMixin = (superclass: any) => + class extends superclass { + marqueeStartScreen_: { x: number; y: number } | null; + + isMarqueeSelecting_: boolean; + + marqueeOverlayElement_: HTMLDivElement | null; + + marqueeModifierAdd_: boolean; + + marqueeModifierToggle_: boolean; + + constructor(config: any) { + super(config); + + this.marqueeStartScreen_ = null; + this.isMarqueeSelecting_ = false; + this.marqueeOverlayElement_ = null; + this.marqueeModifierAdd_ = false; + this.marqueeModifierToggle_ = false; + } + + /** + * Grows the marquee's screen-space rectangle as the pointer moves, activating it (and + * showing the overlay) only once the drag exceeds the same click-vs-drag threshold used + * for atom dragging, so a plain click on empty space still falls through to + * finishMarqueeSelection_'s deselect path instead of drawing a zero-size box. + */ + updateMarqueeState_(event: PointerEvent): void { + if (!this.marqueeStartScreen_) return; + const distance = Math.sqrt( + (event.clientX - this.marqueeStartScreen_.x) ** 2 + + (event.clientY - this.marqueeStartScreen_.y) ** 2, + ); + if (!this.isMarqueeSelecting_) { + if (distance < DRAG_THRESHOLD_PX) return; + this.isMarqueeSelecting_ = true; + this.showMarqueeOverlay_(); + } + this.updateMarqueeOverlay_(event.clientX, event.clientY); + } + + showMarqueeOverlay_(): void { + if (!this.marqueeOverlayElement_) { + const element = document.createElement("div"); + element.style.position = "absolute"; + element.style.border = `1px solid ${MARQUEE_BORDER_COLOR}`; + element.style.backgroundColor = MARQUEE_FILL_COLOR; + element.style.pointerEvents = "none"; + element.style.zIndex = "10"; + this.container.appendChild(element); + this.marqueeOverlayElement_ = element; + } + this.marqueeOverlayElement_.style.display = "block"; + if (this.marqueeStartScreen_) { + this.updateMarqueeOverlay_(this.marqueeStartScreen_.x, this.marqueeStartScreen_.y); + } + } + + updateMarqueeOverlay_(currentX: number, currentY: number): void { + if (!this.marqueeOverlayElement_ || !this.marqueeStartScreen_) return; + const containerRect = this.container.getBoundingClientRect(); + const left = Math.min(this.marqueeStartScreen_.x, currentX) - containerRect.left; + const top = Math.min(this.marqueeStartScreen_.y, currentY) - containerRect.top; + const width = Math.abs(currentX - this.marqueeStartScreen_.x); + const height = Math.abs(currentY - this.marqueeStartScreen_.y); + this.marqueeOverlayElement_.style.left = `${left}px`; + this.marqueeOverlayElement_.style.top = `${top}px`; + this.marqueeOverlayElement_.style.width = `${width}px`; + this.marqueeOverlayElement_.style.height = `${height}px`; + } + + hideMarqueeOverlay_(): void { + if (this.marqueeOverlayElement_) this.marqueeOverlayElement_.style.display = "none"; + } + + /** + * Returns every atom whose projected screen position falls within the given + * (unordered) screen-space rectangle. Atoms behind the camera (or beyond the far + * plane) are excluded via the projected z check. + */ + getAtomsInScreenRect_(rect: { + left: number; + right: number; + top: number; + bottom: number; + }): THREE.Mesh[] { + const boundingRectangle = this.renderer.domElement.getBoundingClientRect(); + return this.collectSelectableAtoms().filter((atom: THREE.Mesh) => { + const projected = atom.position.clone().project(this.camera); + if (projected.z < -1 || projected.z > 1) return false; + const screenX = + boundingRectangle.left + ((projected.x + 1) / 2) * boundingRectangle.width; + const screenY = + boundingRectangle.top + ((1 - projected.y) / 2) * boundingRectangle.height; + return ( + screenX >= rect.left && + screenX <= rect.right && + screenY >= rect.top && + screenY <= rect.bottom + ); + }); + } + + /** + * Resolves a completed (or abandoned) marquee gesture. A release before crossing the + * drag threshold is just a plain click on empty space, so it falls through to the + * existing handlePointerDown click-to-deselect/select path rather than selecting an + * empty rectangle. + */ + finishMarqueeSelection_(event: PointerEvent): void { + const wasSelecting = this.isMarqueeSelecting_; + const startScreen = this.marqueeStartScreen_; + const addModifier = this.marqueeModifierAdd_; + const toggleModifier = this.marqueeModifierToggle_; + this.hideMarqueeOverlay_(); + this.marqueeStartScreen_ = null; + this.isMarqueeSelecting_ = false; + + if (!wasSelecting || !startScreen) { + this.handlePointerDown(event); + return; + } + + const rect = { + left: Math.min(startScreen.x, event.clientX), + right: Math.max(startScreen.x, event.clientX), + top: Math.min(startScreen.y, event.clientY), + bottom: Math.max(startScreen.y, event.clientY), + }; + const hits = this.getAtomsInScreenRect_(rect); + + let nextSelection: THREE.Mesh[]; + if (toggleModifier) { + const hitSet = new Set(hits); + const kept = this.selectedMeshes_.filter((mesh: THREE.Mesh) => !hitSet.has(mesh)); + const added = hits.filter((mesh) => !this.selectedMeshes_.includes(mesh)); + nextSelection = [...kept, ...added]; + } else if (addModifier) { + const added = hits.filter((mesh) => !this.selectedMeshes_.includes(mesh)); + nextSelection = [...this.selectedMeshes_, ...added]; + } else { + nextSelection = hits; + } + + this.setSelectedAtomMeshes(nextSelection); + if (this.settings.onSelectionChanged) { + this.settings.onSelectionChanged( + nextSelection.map((mesh) => mesh.userData.atomicIndex), + ); + } + this.render(); + } + }; diff --git a/src/wave.js b/src/wave.js index 50ba93c3..f73d0c74 100644 --- a/src/wave.js +++ b/src/wave.js @@ -10,9 +10,11 @@ import { BondsMixin } from "./mixins/bonds"; import { BoundaryMixin } from "./mixins/boundary"; import { CellMixin } from "./mixins/cell"; import { ControlsMixin } from "./mixins/controls"; +import { GroupTransformMixin } from "./mixins/group_transform"; import { ImageMixin } from "./mixins/image"; import { InteractiveStructureEditorMixin } from "./mixins/interactive_structure_editor"; import { AllLabelsMixin } from "./mixins/labels/all"; +import { MarqueeSelectionMixin } from "./mixins/marquee_selection"; import { AllMeasurementsMixin } from "./mixins/measurements/all"; import { RepetitionMixin } from "./mixins/repetition"; import SETTINGS from "./settings"; @@ -259,6 +261,8 @@ export class Wave extends mix(WaveBase).with( AllLabelsMixin, AllMeasurementsMixin, ImageMixin, + MarqueeSelectionMixin, + GroupTransformMixin, InteractiveStructureEditorMixin, ) { /**