From 1bd0a389fe3bbaded7a1d07a2e66a58d3b48281d Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Mon, 17 Aug 2026 15:28:07 +0300 Subject: [PATCH 1/7] chore: stop committing dist/, publish WIP builds via CI [release] Mirrors the rollout on the rest of the stack: dist/ gitignored (dropping the "commit dist for other packages to consume" note in the pre-commit hook, no longer accurate), and .github/workflows/release-wip.yml + cleanup-wip-releases.yml wire up mat3ra/actions' composite actions. Bumps @mat3ra/code, @mat3ra/cove, @mat3ra/esse, @mat3ra/made, @mat3ra/utils to their WIP release tarballs. RELEASING.md documents the CI-only publish path. --- .github/workflows/cleanup-wip-releases.yml | 36 + .github/workflows/release-wip.yml | 31 + .gitignore | 1 + .husky/pre-commit | 4 +- RELEASING.md | 69 + dist/MuiClassNameSetup.d.ts | 1 - dist/MuiClassNameSetup.js | 2 - dist/components/AlertDialog.d.ts | 3 - dist/components/AlertDialog.js | 35 - dist/components/EditToolbar.d.ts | 29 - dist/components/EditToolbar.js | 28 - dist/components/FigureExportDialog.d.ts | 31 - dist/components/FigureExportDialog.js | 54 - dist/components/IconsToolbar.d.ts | 23 - dist/components/IconsToolbar.js | 37 - dist/components/KeyboardSheet.d.ts | 23 - dist/components/KeyboardSheet.js | 39 - dist/components/LoadingIndicator.d.ts | 1 - dist/components/LoadingIndicator.js | 6 - dist/components/ModalDialog.d.ts | 25 - dist/components/ModalDialog.js | 48 - dist/components/ModePill.d.ts | 42 - dist/components/ModePill.js | 109 -- dist/components/ParametersMenu.d.ts | 58 - dist/components/ParametersMenu.js | 119 -- dist/components/QuickToggles.d.ts | 27 - dist/components/QuickToggles.js | 61 - dist/components/SelectionInspector.d.ts | 43 - dist/components/SelectionInspector.js | 80 - dist/components/ShowIf.d.ts | 18 - dist/components/ShowIf.js | 18 - dist/components/SquareIconButton.d.ts | 17 - dist/components/SquareIconButton.js | 34 - dist/components/StatusBar.d.ts | 58 - dist/components/StatusBar.js | 150 -- dist/components/ThreeDEditor.d.ts | 539 ------ dist/components/ThreeDEditor.js | 1528 ----------------- dist/components/ToggleIndicator.d.ts | 7 - dist/components/ToggleIndicator.js | 74 - dist/components/ViewerErrorBoundary.d.ts | 44 - dist/components/ViewerErrorBoundary.js | 59 - dist/components/ViewerStatus.d.ts | 22 - dist/components/ViewerStatus.js | 52 - dist/components/WaveComponent.d.ts | 33 - dist/components/WaveComponent.js | 134 -- dist/components/chromeLayout.d.ts | 34 - dist/components/chromeLayout.js | 34 - dist/enums.d.ts | 39 - dist/enums.js | 57 - dist/exports.d.ts | 2 - dist/exports.js | 2 - dist/index.d.ts | 1 - dist/index.js | 35 - dist/mixins/Hashmap.d.ts | 12 - dist/mixins/Hashmap.js | 36 - dist/mixins/atoms.d.ts | 39 - dist/mixins/atoms.js | 124 -- dist/mixins/base.d.ts | 12 - dist/mixins/base.js | 19 - dist/mixins/bonds.d.ts | 75 - dist/mixins/bonds.js | 215 --- dist/mixins/boundary.d.ts | 38 - dist/mixins/boundary.js | 97 -- dist/mixins/cell.d.ts | 58 - dist/mixins/cell.js | 142 -- dist/mixins/controls.d.ts | 9 - dist/mixins/controls.js | 310 ---- dist/mixins/group_transform.d.ts | 60 - dist/mixins/group_transform.js | 145 -- dist/mixins/image.d.ts | 68 - dist/mixins/image.js | 307 ---- dist/mixins/interactive_editor_constants.d.ts | 7 - dist/mixins/interactive_editor_constants.js | 7 - dist/mixins/interactive_structure_editor.d.ts | 282 --- dist/mixins/interactive_structure_editor.js | 1071 ------------ dist/mixins/labels/all.d.ts | 15 - dist/mixins/labels/all.js | 39 - dist/mixins/labels/angle.d.ts | 23 - dist/mixins/labels/angle.js | 15 - dist/mixins/labels/as_points.d.ts | 22 - dist/mixins/labels/as_points.js | 37 - dist/mixins/labels/base.d.ts | 30 - dist/mixins/labels/base.js | 117 -- dist/mixins/labels/coordinate.d.ts | 25 - dist/mixins/labels/coordinate.js | 19 - dist/mixins/labels/distance.d.ts | 25 - dist/mixins/labels/distance.js | 18 - dist/mixins/labels/element.d.ts | 24 - dist/mixins/labels/element.js | 14 - dist/mixins/lines/LinesManager.d.ts | 31 - dist/mixins/lines/LinesManager.js | 108 -- dist/mixins/listeners/mixins.d.ts | 26 - dist/mixins/listeners/mixins.js | 44 - dist/mixins/marquee_selection.d.ts | 52 - dist/mixins/marquee_selection.js | 138 -- .../MeasurementSettingsHandler.d.ts | 29 - .../MeasurementSettingsHandler.js | 49 - dist/mixins/measurements/all.d.ts | 28 - dist/mixins/measurements/all.js | 58 - dist/mixins/measurements/angle.d.ts | 20 - dist/mixins/measurements/angle.js | 63 - dist/mixins/measurements/base.d.ts | 77 - dist/mixins/measurements/base.js | 242 --- dist/mixins/measurements/coordinate.d.ts | 28 - dist/mixins/measurements/coordinate.js | 18 - dist/mixins/measurements/distance.d.ts | 21 - dist/mixins/measurements/distance.js | 61 - dist/mixins/repetition.d.ts | 47 - dist/mixins/repetition.js | 131 -- dist/mixins/types/atoms.d.ts | 16 - dist/mixins/types/atoms.js | 1 - dist/mixins/utils.d.ts | 18 - dist/mixins/utils.js | 71 - dist/mixins/utils_three.d.ts | 57 - dist/mixins/utils_three.js | 134 -- dist/settings.d.ts | 201 --- dist/settings.js | 205 --- dist/stylesheets/main.css | 26 - dist/utils.d.ts | 7 - dist/utils.js | 144 -- dist/utils/editActions.d.ts | 8 - dist/utils/editActions.js | 42 - dist/utils/figureExport.d.ts | 183 -- dist/utils/figureExport.js | 264 --- dist/utils/inputCapabilities.d.ts | 42 - dist/utils/inputCapabilities.js | 65 - dist/utils/keyBindings.d.ts | 74 - dist/utils/keyBindings.js | 174 -- dist/utils/measurementReadout.d.ts | 33 - dist/utils/measurementReadout.js | 67 - dist/utils/useObservedWidth.d.ts | 22 - dist/utils/useObservedWidth.js | 62 - dist/utils/viewSettingsUrl.d.ts | 39 - dist/utils/viewSettingsUrl.js | 149 -- dist/wave.d.ts | 16 - dist/wave.js | 318 ---- package-lock.json | 40 +- package.json | 10 +- 138 files changed, 163 insertions(+), 11208 deletions(-) create mode 100644 .github/workflows/cleanup-wip-releases.yml create mode 100644 .github/workflows/release-wip.yml create mode 100644 RELEASING.md delete mode 100644 dist/MuiClassNameSetup.d.ts delete mode 100644 dist/MuiClassNameSetup.js delete mode 100644 dist/components/AlertDialog.d.ts delete mode 100644 dist/components/AlertDialog.js delete mode 100644 dist/components/EditToolbar.d.ts delete mode 100644 dist/components/EditToolbar.js delete mode 100644 dist/components/FigureExportDialog.d.ts delete mode 100644 dist/components/FigureExportDialog.js delete mode 100644 dist/components/IconsToolbar.d.ts delete mode 100644 dist/components/IconsToolbar.js delete mode 100644 dist/components/KeyboardSheet.d.ts delete mode 100644 dist/components/KeyboardSheet.js delete mode 100644 dist/components/LoadingIndicator.d.ts delete mode 100644 dist/components/LoadingIndicator.js delete mode 100644 dist/components/ModalDialog.d.ts delete mode 100644 dist/components/ModalDialog.js delete mode 100644 dist/components/ModePill.d.ts delete mode 100644 dist/components/ModePill.js delete mode 100644 dist/components/ParametersMenu.d.ts delete mode 100644 dist/components/ParametersMenu.js delete mode 100644 dist/components/QuickToggles.d.ts delete mode 100644 dist/components/QuickToggles.js delete mode 100644 dist/components/SelectionInspector.d.ts delete mode 100644 dist/components/SelectionInspector.js delete mode 100644 dist/components/ShowIf.d.ts delete mode 100644 dist/components/ShowIf.js delete mode 100644 dist/components/SquareIconButton.d.ts delete mode 100644 dist/components/SquareIconButton.js delete mode 100644 dist/components/StatusBar.d.ts delete mode 100644 dist/components/StatusBar.js delete mode 100644 dist/components/ThreeDEditor.d.ts delete mode 100644 dist/components/ThreeDEditor.js delete mode 100644 dist/components/ToggleIndicator.d.ts delete mode 100644 dist/components/ToggleIndicator.js delete mode 100644 dist/components/ViewerErrorBoundary.d.ts delete mode 100644 dist/components/ViewerErrorBoundary.js delete mode 100644 dist/components/ViewerStatus.d.ts delete mode 100644 dist/components/ViewerStatus.js delete mode 100644 dist/components/WaveComponent.d.ts delete mode 100644 dist/components/WaveComponent.js delete mode 100644 dist/components/chromeLayout.d.ts delete mode 100644 dist/components/chromeLayout.js delete mode 100644 dist/enums.d.ts delete mode 100644 dist/enums.js delete mode 100644 dist/exports.d.ts delete mode 100644 dist/exports.js delete mode 100644 dist/index.d.ts delete mode 100644 dist/index.js delete mode 100644 dist/mixins/Hashmap.d.ts delete mode 100644 dist/mixins/Hashmap.js delete mode 100644 dist/mixins/atoms.d.ts delete mode 100644 dist/mixins/atoms.js delete mode 100644 dist/mixins/base.d.ts delete mode 100644 dist/mixins/base.js delete mode 100644 dist/mixins/bonds.d.ts delete mode 100644 dist/mixins/bonds.js delete mode 100644 dist/mixins/boundary.d.ts delete mode 100644 dist/mixins/boundary.js delete mode 100644 dist/mixins/cell.d.ts delete mode 100644 dist/mixins/cell.js delete mode 100644 dist/mixins/controls.d.ts delete mode 100644 dist/mixins/controls.js delete mode 100644 dist/mixins/group_transform.d.ts delete mode 100644 dist/mixins/group_transform.js delete mode 100644 dist/mixins/image.d.ts delete mode 100644 dist/mixins/image.js delete mode 100644 dist/mixins/interactive_editor_constants.d.ts delete mode 100644 dist/mixins/interactive_editor_constants.js delete mode 100644 dist/mixins/interactive_structure_editor.d.ts delete mode 100644 dist/mixins/interactive_structure_editor.js delete mode 100644 dist/mixins/labels/all.d.ts delete mode 100644 dist/mixins/labels/all.js delete mode 100644 dist/mixins/labels/angle.d.ts delete mode 100644 dist/mixins/labels/angle.js delete mode 100644 dist/mixins/labels/as_points.d.ts delete mode 100644 dist/mixins/labels/as_points.js delete mode 100644 dist/mixins/labels/base.d.ts delete mode 100644 dist/mixins/labels/base.js delete mode 100644 dist/mixins/labels/coordinate.d.ts delete mode 100644 dist/mixins/labels/coordinate.js delete mode 100644 dist/mixins/labels/distance.d.ts delete mode 100644 dist/mixins/labels/distance.js delete mode 100644 dist/mixins/labels/element.d.ts delete mode 100644 dist/mixins/labels/element.js delete mode 100644 dist/mixins/lines/LinesManager.d.ts delete mode 100644 dist/mixins/lines/LinesManager.js delete mode 100644 dist/mixins/listeners/mixins.d.ts delete mode 100644 dist/mixins/listeners/mixins.js delete mode 100644 dist/mixins/marquee_selection.d.ts delete mode 100644 dist/mixins/marquee_selection.js delete mode 100644 dist/mixins/measurements/MeasurementSettingsHandler.d.ts delete mode 100644 dist/mixins/measurements/MeasurementSettingsHandler.js delete mode 100644 dist/mixins/measurements/all.d.ts delete mode 100644 dist/mixins/measurements/all.js delete mode 100644 dist/mixins/measurements/angle.d.ts delete mode 100644 dist/mixins/measurements/angle.js delete mode 100644 dist/mixins/measurements/base.d.ts delete mode 100644 dist/mixins/measurements/base.js delete mode 100644 dist/mixins/measurements/coordinate.d.ts delete mode 100644 dist/mixins/measurements/coordinate.js delete mode 100644 dist/mixins/measurements/distance.d.ts delete mode 100644 dist/mixins/measurements/distance.js delete mode 100644 dist/mixins/repetition.d.ts delete mode 100644 dist/mixins/repetition.js delete mode 100644 dist/mixins/types/atoms.d.ts delete mode 100644 dist/mixins/types/atoms.js delete mode 100644 dist/mixins/utils.d.ts delete mode 100644 dist/mixins/utils.js delete mode 100644 dist/mixins/utils_three.d.ts delete mode 100644 dist/mixins/utils_three.js delete mode 100644 dist/settings.d.ts delete mode 100644 dist/settings.js delete mode 100644 dist/stylesheets/main.css delete mode 100644 dist/utils.d.ts delete mode 100644 dist/utils.js delete mode 100644 dist/utils/editActions.d.ts delete mode 100644 dist/utils/editActions.js delete mode 100644 dist/utils/figureExport.d.ts delete mode 100644 dist/utils/figureExport.js delete mode 100644 dist/utils/inputCapabilities.d.ts delete mode 100644 dist/utils/inputCapabilities.js delete mode 100644 dist/utils/keyBindings.d.ts delete mode 100644 dist/utils/keyBindings.js delete mode 100644 dist/utils/measurementReadout.d.ts delete mode 100644 dist/utils/measurementReadout.js delete mode 100644 dist/utils/useObservedWidth.d.ts delete mode 100644 dist/utils/useObservedWidth.js delete mode 100644 dist/utils/viewSettingsUrl.d.ts delete mode 100644 dist/utils/viewSettingsUrl.js delete mode 100644 dist/wave.d.ts delete mode 100644 dist/wave.js diff --git a/.github/workflows/cleanup-wip-releases.yml b/.github/workflows/cleanup-wip-releases.yml new file mode 100644 index 00000000..9a69c39b --- /dev/null +++ b/.github/workflows/cleanup-wip-releases.yml @@ -0,0 +1,36 @@ +--- + +name: Clean up stale WIP release tarballs + +on: + schedule: + - cron: '0 6 * * 1' # every Monday 06:00 UTC + workflow_dispatch: + inputs: + dry-run: + description: Log keep/delete decisions without deleting anything + type: boolean + default: true + +jobs: + cleanup-wip-releases: + runs-on: ubuntu-latest + steps: + - name: Checkout this repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Checkout actions repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: mat3ra/actions + # TODO: drop `ref` once mat3ra/actions' js/cleanup-wip-releases merges to main + ref: chore/SOF-7942-1 + token: ${{ secrets.BOT_GITHUB_TOKEN }} + path: actions + + - name: Clean up stale WIP releases + uses: ./actions/js/cleanup-wip-releases + with: + # Scheduled runs always delete; workflow_dispatch defaults to dry-run. + dry-run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run || 'false' }} + github-token: ${{ secrets.BOT_GITHUB_TOKEN }} diff --git a/.github/workflows/release-wip.yml b/.github/workflows/release-wip.yml new file mode 100644 index 00000000..b26b47e3 --- /dev/null +++ b/.github/workflows/release-wip.yml @@ -0,0 +1,31 @@ +--- + +name: Publish WIP release tarball + +on: push + +jobs: + release-wip: + if: contains(github.event.head_commit.message, '[release]') + runs-on: ubuntu-latest + steps: + - name: Checkout this repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + lfs: true + + - name: Checkout actions repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: mat3ra/actions + # TODO: drop `ref` once mat3ra/actions' js/release-wip merges to main + ref: chore/SOF-7942-1 + token: ${{ secrets.BOT_GITHUB_TOKEN }} + path: actions + + - name: Publish WIP release tarball + uses: ./actions/js/release-wip + with: + package-name: wave.js + build-script: transpile + github-token: ${{ secrets.BOT_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index cd77fb06..46bf1e94 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ build/ +dist/ node_modules/ .eslintcache .nyc_output/ diff --git a/.husky/pre-commit b/.husky/pre-commit index bbe04923..99187e6f 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,7 +2,5 @@ . "$(dirname "$0")/_/husky.sh" npx lint-staged --allow-empty -# dist/ is committed so other packages can consume it straight from the repository; -# rebuild and stage it so it never drifts from src/. npm run transpile -git add dist +# dist/ is gitignored - not staged here diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..9facbf33 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,69 @@ +# Releasing WIP builds for consumers + +`dist/` is build output and is gitignored — it is not committed on any branch. Published +releases (`npm publish`, tagged versions) are unaffected: `npm pack`/`npm publish` build +their file list from the `files` field in `package.json`, not from what's tracked in git. + +To let a consumer (e.g. `web-app`) install a not-yet-mergeable WIP commit without +`github:mat3ra/wave.js#branch` (which has no way to build `dist/` on install), CI publishes +a pre-release tarball asset instead. + +Releases are tagged per **commit**, not per branch: `wip-`. Each commit +gets its own immutable tag/asset URL, so a consumer pinned to a specific commit's tarball +never has the content underneath that URL silently change. + +## Publishing (CI only) + +Include `[release]` anywhere in a commit message and push. `.github/workflows/release-wip.yml` +runs the `js/release-wip` action (from [`mat3ra/actions`](https://github.com/mat3ra/actions)) +on that push, which builds, packs, and publishes the pre-release tarball asset for that +commit. Commits without the marker don't trigger it, so routine pushes stay quiet. + +There is no local/manual equivalent — building and publishing only happens in CI, so the +tarball a consumer installs is always reproducible from a pushed commit, not from +whatever happens to be on someone's machine. + +The download URL follows this shape: + +```text +https://github.com/mat3ra/wave.js/releases/download/wip-/wave.js.tgz +``` + +Re-triggering CI **on the same commit** (e.g. re-running the workflow) re-uploads over +that commit's existing asset (`gh release upload ... --clobber`) rather than minting a +duplicate. A new commit always gets a new tag/URL. + +## Reinstalling in a consumer after a same-commit rebuild + +Because a same-commit rebuild re-uploads over that commit's existing tag/asset, the +download URL doesn't change — which means a plain `npm install` in the consumer won't +pick up the rebuild: npm's cache stores the tarball keyed by URL, and +`package-lock.json` pins the `integrity` hash from the first install, so a +same-URL-but-changed-content refetch either gets served stale from cache or fails with +`EINTEGRITY`. Force a real refetch instead. In `web-app`, use the wrapper script from +repo root: + +```bash +npm run mat3ra:install -- wave.js wip- +``` + +(`web-app/scripts/mat3ra-install.sh` — installs `@mat3ra/wave.js@` with +`--legacy-peer-deps --force`.) + +Delete a pre-release once its commit merges and a real published version supersedes it: + +```bash +gh release delete wip- --yes +``` + +(Or let `cleanup-wip-releases.yml`, below, do it automatically once that commit is no +longer any branch's tip.) + +## Automatic cleanup + +`.github/workflows/cleanup-wip-releases.yml` runs weekly (Monday 06:00 UTC) via the +`js/cleanup-wip-releases` action and deletes any `wip-*` release whose commit is no +longer the tip of any branch. This is branch-aware, not age-based, and never deletes +anything not marked pre-release, regardless of tag. Trigger it manually from the Actions +tab (`workflow_dispatch`, defaults to `dry-run: true`) to test or force an off-schedule +cleanup. diff --git a/dist/MuiClassNameSetup.d.ts b/dist/MuiClassNameSetup.d.ts deleted file mode 100644 index cb0ff5c3..00000000 --- a/dist/MuiClassNameSetup.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/MuiClassNameSetup.js b/dist/MuiClassNameSetup.js deleted file mode 100644 index 5032313c..00000000 --- a/dist/MuiClassNameSetup.js +++ /dev/null @@ -1,2 +0,0 @@ -import { unstable_ClassNameGenerator as ClassNameGenerator } from "@mui/material/className"; -ClassNameGenerator.configure((componentName) => `wave-${componentName}`); diff --git a/dist/components/AlertDialog.d.ts b/dist/components/AlertDialog.d.ts deleted file mode 100644 index 126c65d0..00000000 --- a/dist/components/AlertDialog.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const AlertDialog: React.ForwardRefExoticComponent>; -export default AlertDialog; -import React from "react"; diff --git a/dist/components/AlertDialog.js b/dist/components/AlertDialog.js deleted file mode 100644 index bd4bf1a4..00000000 --- a/dist/components/AlertDialog.js +++ /dev/null @@ -1,35 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -// TODO: move that component to cove.js and reuse it here -import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogContentText from "@mui/material/DialogContentText"; -import DialogTitle from "@mui/material/DialogTitle"; -import React, { useImperativeHandle, useState } from "react"; -export const AlertDialog = React.forwardRef((props, ref) => { - const [isOpened, setIsOpened] = useState(false); - const [content, setContent] = useState(""); - const [buttons, setButtons] = useState([]); - const [title, setTitle] = useState(""); - /* eslint-disable no-shadow */ - const handleOpen = ({ content, buttons = [], title }) => { - setTitle(title); - setButtons(buttons); - setContent(content); - setIsOpened(true); - }; - const handleClose = () => { - setIsOpened(false); - }; - const renderButtons = () => { - return buttons.map(({ text, onClick }) => { - return (_jsx(Button, { onClick: onClick, children: text }, text)); - }); - }; - useImperativeHandle(ref, () => { - return { open: handleOpen, close: handleClose }; - }, []); - return (_jsxs(Dialog, { open: isOpened, onClose: handleClose, "aria-labelledby": "alert-dialog-title", "aria-describedby": "alert-dialog-description", children: [_jsx(DialogTitle, { id: "alert-dialog-title", children: title }), _jsx(DialogContent, { children: _jsx(DialogContentText, { id: "alert-dialog-description", children: content }) }), _jsx(DialogActions, { children: renderButtons() })] })); -}); -export default AlertDialog; diff --git a/dist/components/EditToolbar.d.ts b/dist/components/EditToolbar.d.ts deleted file mode 100644 index bdd0255f..00000000 --- a/dist/components/EditToolbar.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * The edit tools, as icons only. - * - * Extracted from `ThreeDEditor.renderEditToolbar`, which packed eight icon buttons *and* four - * text fields into a single 84 px column - roughly 600 px tall, with no scroll, so in any viewer - * shorter than that the coordinate fields were simply cut off (finding F1). Splitting the tools - * from the data also stops the two reading as one undifferentiated stack: these are verbs, and - * they now share the visual language of the left menu strip. The data lives in - * `SelectionInspector`. - */ -export type TransformMode = "translate" | "rotate"; -export interface EditToolbarProps { - activeTransformMode?: TransformMode; - /** Number of selected atoms; drives the disabled states and the plural tooltips. */ - selectedCount?: number; - /** Element the Add button will insert. */ - defaultElement?: string; - canUndo?: boolean; - canRedo?: boolean; - onSetTransformMode?: (mode: TransformMode) => void; - onAddAtom?: () => void; - onCloneSelected?: () => void; - onRemoveSelected?: () => void; - onFocusCamera?: () => void; - onUndo?: () => void; - onRedo?: () => void; -} -declare function EditToolbar(props: EditToolbarProps): import("react/jsx-runtime").JSX.Element; -export default EditToolbar; diff --git a/dist/components/EditToolbar.js b/dist/components/EditToolbar.js deleted file mode 100644 index 53d7afab..00000000 --- a/dist/components/EditToolbar.js +++ /dev/null @@ -1,28 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import AddCircleOutline from "@mui/icons-material/AddCircleOutline"; -import CenterFocusStrong from "@mui/icons-material/CenterFocusStrong"; -import ContentCopy from "@mui/icons-material/ContentCopy"; -import DeleteIcon from "@mui/icons-material/Delete"; -import OpenWith from "@mui/icons-material/OpenWith"; -import Redo from "@mui/icons-material/Redo"; -import RotateRight from "@mui/icons-material/RotateRight"; -import Undo from "@mui/icons-material/Undo"; -import ButtonGroup from "@mui/material/ButtonGroup"; -import Paper from "@mui/material/Paper"; -import Stack from "@mui/material/Stack"; -import settings from "../settings"; -import SquareIconButton from "./SquareIconButton"; -function EditToolbar(props) { - const { activeTransformMode = "translate", selectedCount = 0, defaultElement = "Si", canUndo = false, canRedo = false, onSetTransformMode, onAddAtom, onCloneSelected, onRemoveSelected, onFocusCamera, onUndo, onRedo, } = props; - const hasSelection = selectedCount > 0; - // Rotating a single sphere about its own centre changes nothing structurally (D4), so rotate - // only means something for a group about its centroid. - const canRotate = selectedCount > 1; - const isGroup = selectedCount > 1; - return (_jsx(Paper, { elevation: 2, "data-name": "EditToolbar", sx: { boxShadow: 4 }, children: _jsxs(Stack, { alignItems: "center", padding: 0.5, spacing: 0.5, children: [_jsxs(ButtonGroup, { orientation: "vertical", variant: "outlined", color: "inherit", children: [_jsx(SquareIconButton, { title: "Translate Mode", onClick: () => onSetTransformMode === null || onSetTransformMode === void 0 ? void 0 : onSetTransformMode("translate"), children: _jsx(OpenWith, { color: activeTransformMode === "translate" ? "primary" : "inherit" }) }), _jsx(SquareIconButton, { title: canRotate ? "Rotate Mode" : "Select 2+ atoms to rotate as a group", disabled: !canRotate, onClick: () => onSetTransformMode === null || onSetTransformMode === void 0 ? void 0 : onSetTransformMode("rotate"), children: _jsx(RotateRight, { color: activeTransformMode === "rotate" ? "primary" : "inherit" }) })] }), _jsxs(ButtonGroup, { orientation: "vertical", variant: "outlined", color: "inherit", children: [_jsx(SquareIconButton, { title: `Add Atom (${defaultElement})`, onClick: () => onAddAtom === null || onAddAtom === void 0 ? void 0 : onAddAtom(), children: _jsx(AddCircleOutline, {}) }), _jsx(SquareIconButton, { title: isGroup - ? `Clone ${selectedCount} Selected Atoms` - : "Clone Selected Atom", disabled: !hasSelection, onClick: () => onCloneSelected === null || onCloneSelected === void 0 ? void 0 : onCloneSelected(), children: _jsx(ContentCopy, {}) }), _jsx(SquareIconButton, { title: isGroup - ? `Delete ${selectedCount} Selected Atoms` - : "Delete Selected Atom", disabled: !hasSelection, onClick: () => onRemoveSelected === null || onRemoveSelected === void 0 ? void 0 : onRemoveSelected(), children: _jsx(DeleteIcon, {}) })] }), _jsxs(ButtonGroup, { orientation: "vertical", variant: "outlined", color: "inherit", children: [_jsx(SquareIconButton, { title: `Focus Camera on Selection [${settings.hotKeysConfig.focusCameraOnSelection.toUpperCase()}]`, disabled: !hasSelection, onClick: () => onFocusCamera === null || onFocusCamera === void 0 ? void 0 : onFocusCamera(), children: _jsx(CenterFocusStrong, {}) }), _jsx(SquareIconButton, { title: "Undo", disabled: !canUndo, onClick: () => onUndo === null || onUndo === void 0 ? void 0 : onUndo(), children: _jsx(Undo, {}) }), _jsx(SquareIconButton, { title: "Redo", disabled: !canRedo, onClick: () => onRedo === null || onRedo === void 0 ? void 0 : onRedo(), children: _jsx(Redo, {}) })] })] }) })); -} -export default EditToolbar; diff --git a/dist/components/FigureExportDialog.d.ts b/dist/components/FigureExportDialog.d.ts deleted file mode 100644 index f9b468e3..00000000 --- a/dist/components/FigureExportDialog.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { FigureBackgroundId } from "../utils/figureExport"; -/** - * Figure export (U-12). Three decisions, each one of the things a screenshot of the canvas cannot - * give: how many pixels, what background, and whether to annotate the scale. - * - * The panel states the resulting size in millimetres as well as pixels, because the question behind - * this dialog is almost always "will this be sharp in the paper". - */ -export interface FigureExportOptions { - width: number; - height: number; - background: FigureBackgroundId; - includeScaleBar: boolean; -} -export interface FigureExportDialogProps { - isOpen?: boolean; - onClose?: () => void; - onExport?: (options: FigureExportOptions) => void; - /** Current canvas size, used for the "On-screen" preset and to keep every other preset's aspect. */ - viewportWidth?: number; - viewportHeight?: number; - /** Largest dimension the GL context will render; requests above it are scaled down. */ - maxDimension?: number; - /** - * Whether the viewer is currently using the orthographic camera. A perspective projection has - * no single scale, so the scale bar carries a caveat there and none here. - */ - isCameraOrthographic?: boolean; -} -declare function FigureExportDialog(props: FigureExportDialogProps): import("react/jsx-runtime").JSX.Element; -export default FigureExportDialog; diff --git a/dist/components/FigureExportDialog.js b/dist/components/FigureExportDialog.js deleted file mode 100644 index e0eeb9e6..00000000 --- a/dist/components/FigureExportDialog.js +++ /dev/null @@ -1,54 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import Alert from "@mui/material/Alert"; -import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import Radio from "@mui/material/Radio"; -import RadioGroup from "@mui/material/RadioGroup"; -import Stack from "@mui/material/Stack"; -import Switch from "@mui/material/Switch"; -import TextField from "@mui/material/TextField"; -import ToggleButton from "@mui/material/ToggleButton"; -import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; -import Typography from "@mui/material/Typography"; -import { useMemo, useState } from "react"; -import { describeFigureResolution, FIGURE_BACKGROUNDS, FIGURE_SIZE_PRESETS, getFigureBackground, getFigureResolution, getFigureSizePreset, } from "../utils/figureExport"; -function FigureExportDialog(props) { - const { isOpen = false, onClose, onExport, viewportWidth = 0, viewportHeight = 0, maxDimension, isCameraOrthographic = false, } = props; - const [presetId, setPresetId] = useState("double-column"); - const [backgroundId, setBackgroundId] = useState("white"); - const [includeScaleBar, setIncludeScaleBar] = useState(true); - const [customWidth, setCustomWidth] = useState("1600"); - const [customHeight, setCustomHeight] = useState("1200"); - const resolution = useMemo(() => getFigureResolution({ - presetId, - customWidth: Number(customWidth), - customHeight: Number(customHeight), - viewportWidth, - viewportHeight, - maxDimension, - }), [presetId, customWidth, customHeight, viewportWidth, viewportHeight, maxDimension]); - const preset = getFigureSizePreset(presetId); - const background = getFigureBackground(backgroundId); - const handleExport = () => { - onExport === null || onExport === void 0 ? void 0 : onExport({ - width: resolution.width, - height: resolution.height, - background: background.id, - includeScaleBar, - }); - onClose === null || onClose === void 0 ? void 0 : onClose(); - }; - return (_jsxs(Dialog, { open: isOpen, onClose: onClose, maxWidth: "sm", fullWidth: true, "aria-labelledby": "figure-export-title", "data-name": "FigureExportDialog", children: [_jsx(DialogTitle, { id: "figure-export-title", sx: { pb: 1 }, children: _jsx(Typography, { variant: "h6", component: "span", children: "Export figure" }) }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 2.5, children: [_jsxs(Stack, { spacing: 1, children: [_jsx(Typography, { variant: "caption", fontWeight: "bold", color: "primary.main", sx: { letterSpacing: "0.06em", textTransform: "uppercase" }, children: "Size" }), _jsx(ToggleButtonGroup, { exclusive: true, size: "small", value: presetId, - // MUI passes null when the active button is clicked again; keeping the - // current value avoids a state with no size selected at all. - onChange: (_event, value) => value && setPresetId(value), "aria-label": "Figure size", sx: { flexWrap: "wrap" }, children: FIGURE_SIZE_PRESETS.map((sizePreset) => (_jsx(ToggleButton, { value: sizePreset.id, "data-name": `FigureSize-${sizePreset.id}`, title: sizePreset.hint, children: sizePreset.label }, sizePreset.id))) }), preset.id === "custom" ? (_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(TextField, { size: "small", type: "number", label: "Width", value: customWidth, onChange: (event) => setCustomWidth(event.target.value), inputProps: { "data-name": "FigureCustomWidth", min: 64 }, sx: { width: "8em" } }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: "\u00D7" }), _jsx(TextField, { size: "small", type: "number", label: "Height", value: customHeight, onChange: (event) => setCustomHeight(event.target.value), inputProps: { "data-name": "FigureCustomHeight", min: 64 }, sx: { width: "8em" } })] })) : (_jsxs(Typography, { variant: "caption", color: "text.secondary", children: [preset.hint, " Height follows the canvas aspect ratio, so nothing is stretched."] })), _jsx(Typography, { variant: "body2", "data-name": "FigureResolutionSummary", sx: { fontFamily: "monospace" }, children: describeFigureResolution(resolution) }), resolution.isClamped && (_jsx(Alert, { severity: "info", "data-name": "FigureClampedNotice", children: "Scaled down to what this graphics context can render in one pass." }))] }), _jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "caption", fontWeight: "bold", color: "primary.main", sx: { letterSpacing: "0.06em", textTransform: "uppercase" }, children: "Background" }), _jsx(RadioGroup, { value: background.id, onChange: (event) => setBackgroundId(event.target.value), children: FIGURE_BACKGROUNDS.map((option) => (_jsx(FormControlLabel, { value: option.id, control: _jsx(Radio, { size: "small", inputProps: { - "data-name": `FigureBackground-${option.id}`, - } }), label: _jsxs(Stack, { children: [_jsx(Typography, { variant: "body2", children: option.label }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: option.hint })] }), sx: { alignItems: "flex-start", mb: 0.5 } }, option.id))) })] }), _jsxs(Stack, { spacing: 0.5, children: [_jsx(FormControlLabel, { control: _jsx(Switch, { size: "small", checked: includeScaleBar, onChange: (event) => setIncludeScaleBar(event.target.checked), inputProps: { - "data-name": "FigureScaleBarToggle", - } }), label: _jsx(Typography, { variant: "body2", children: "Scale bar" }) }), includeScaleBar && !isCameraOrthographic && (_jsx(Alert, { severity: "warning", "data-name": "FigureScaleBarPerspectiveNotice", children: "A perspective projection has no single scale: the bar is exact only in the plane through the pivot point. Switch to the orthographic camera for a strictly correct bar." }))] })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: onClose, "data-name": "FigureExportCancel", children: "Cancel" }), _jsx(Button, { variant: "contained", onClick: handleExport, "data-name": "FigureExportConfirm", children: "Export PNG" })] })] })); -} -export default FigureExportDialog; diff --git a/dist/components/IconsToolbar.d.ts b/dist/components/IconsToolbar.d.ts deleted file mode 100644 index 963424c8..00000000 --- a/dist/components/IconsToolbar.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NestedDropdownAction, NestedDropdownProps } from "@mat3ra/cove/dist/mui/components/nested-dropdown/NestedDropdown"; -import React from "react"; -interface ToolbarConfig { - id: string; - key?: string; - title: string; - header?: string; - onClick: (...args: React.MouseEvent[]) => void; - leftIcon: React.ReactNode; - /** Renders the button inert and greyed - used by items whose action is not currently available. */ - disabled?: boolean; - actions?: NestedDropdownAction[]; - contentObject?: NestedDropdownProps["contentObject"]; - paperPlacement?: NestedDropdownProps["paperPlacement"]; -} -interface IconToolbarProps { - handleToggleInteractive: () => void; - isInteractive: boolean; - toolbarConfig: ToolbarConfig[]; - paperPlacement?: NestedDropdownProps["paperPlacement"]; -} -declare function IconsToolbar(props: IconToolbarProps): import("react/jsx-runtime").JSX.Element; -export default IconsToolbar; diff --git a/dist/components/IconsToolbar.js b/dist/components/IconsToolbar.js deleted file mode 100644 index a82547ef..00000000 --- a/dist/components/IconsToolbar.js +++ /dev/null @@ -1,37 +0,0 @@ -import { createElement as _createElement } from "react"; -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import IconByName from "@mat3ra/cove/dist/mui/components/icon"; -import NestedDropdown from "@mat3ra/cove/dist/mui/components/nested-dropdown/NestedDropdown"; -import PowerSettingsNew from "@mui/icons-material/PowerSettingsNew"; -import ButtonGroup from "@mui/material/ButtonGroup"; -import Paper from "@mui/material/Paper"; -import { useTheme } from "@mui/material/styles"; -import useMediaQuery from "@mui/material/useMediaQuery"; -import SquareIconButton from "./SquareIconButton"; -function IconsToolbar(props) { - const { isInteractive, handleToggleInteractive, toolbarConfig } = props; - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down("sm")); - const toolbarStyle = { - position: "absolute", - top: "1em", - left: "1em", - boxShadow: theme.shadows[4], - }; - const paperSx = { - marginLeft: theme.spacing(1), - boxShadow: theme.shadows[4], - }; - return (_jsx(Paper, { elevation: 2, children: _jsxs(ButtonGroup, { orientation: "vertical", sx: toolbarStyle, variant: "outlined", color: "inherit", children: [_jsx(SquareIconButton, { size: "large", title: "Interactive", "data-name": "Interactive", onClick: handleToggleInteractive, children: isInteractive ? (_jsx(IconByName, { name: "actions.close", sx: { color: theme.palette.warning.main } })) : (_jsx(PowerSettingsNew, {})) }, "toggle-interactive"), isInteractive && - toolbarConfig.map((config) => { - if (config.actions || config.contentObject) { - return (_createElement(NestedDropdown - /* eslint-disable-next-line react/jsx-props-no-spreading */ - , { ...config, actions: config.actions, contentObject: config.contentObject, key: config.key || config.id, "data-name": config.id, paperPlacement: config.paperPlacement || "right-start", paperSx: paperSx, isMobile: isMobile }, - _jsx(SquareIconButton, { "data-name": config.id, title: config.title, onClick: config.onClick, children: config.leftIcon }, `button-${config.key}` || `button-${config.id}`))); - } - const { id, key, title, onClick, leftIcon, disabled } = config; - return (_jsx(SquareIconButton, { "data-name": id, title: title, disabled: disabled, onClick: onClick, children: leftIcon }, key || id)); - })] }, "toolbar-button-group") })); -} -export default IconsToolbar; diff --git a/dist/components/KeyboardSheet.d.ts b/dist/components/KeyboardSheet.d.ts deleted file mode 100644 index 89118c2f..00000000 --- a/dist/components/KeyboardSheet.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * The keyboard sheet. Every row is generated from settings (hotKeysConfig and editorKeysConfig) - * plus the pointer gestures, so rebinding a key updates the sheet with it - see - * utils/keyBindings.ts for why that matters (finding F3, defect D2). - */ -export interface KeyboardSheetProps { - isOpen?: boolean; - onClose?: () => void; - /** Mirrors ThreeDEditor's prop: without it the edit bindings do not exist to advertise. */ - editable?: boolean; - /** - * Whether to list the touch gestures (U-13). Defaults to whether the device can produce touch - * input at all - see getKeyBindings - and is a prop so a test can assert both shapes. - */ - includeTouch?: boolean; - /** - * Whether the primary pointer is coarse. Decides the group order (touch first where touch is how - * the viewer is driven) and suppresses the key-based close hint. A prop so a test can assert both. - */ - isCoarsePointer?: boolean; -} -declare function KeyboardSheet(props: KeyboardSheetProps): import("react/jsx-runtime").JSX.Element; -export default KeyboardSheet; diff --git a/dist/components/KeyboardSheet.js b/dist/components/KeyboardSheet.js deleted file mode 100644 index a27ff03e..00000000 --- a/dist/components/KeyboardSheet.js +++ /dev/null @@ -1,39 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import Divider from "@mui/material/Divider"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import { hasCoarsePointer } from "../utils/inputCapabilities"; -import { getGroupedKeyBindings, getModifierLabel } from "../utils/keyBindings"; -function KeyRow({ label, keys }) { - return (_jsxs(Stack, { direction: "row", spacing: 2, justifyContent: "space-between", alignItems: "baseline", children: [_jsx(Typography, { variant: "body2", color: "text.secondary", children: label }), _jsx(Box, { component: "kbd", sx: { - fontFamily: "monospace", - fontSize: "0.78rem", - whiteSpace: "nowrap", - color: "text.primary", - }, children: keys })] })); -} -function KeyboardSheet(props) { - const { isOpen = false, onClose, editable = true, includeTouch, isCoarsePointer } = props; - const isCoarse = isCoarsePointer !== null && isCoarsePointer !== void 0 ? isCoarsePointer : hasCoarsePointer(); - const sections = getGroupedKeyBindings({ editable, includeTouch, touchFirst: isCoarse }); - return (_jsxs(Dialog, { open: isOpen, onClose: onClose, maxWidth: "md", fullWidth: true, "aria-labelledby": "keyboard-sheet-title", "data-name": "KeyboardSheet", children: [_jsx(DialogTitle, { id: "keyboard-sheet-title", sx: { pb: 1 }, children: _jsxs(Stack, { direction: "row", justifyContent: "space-between", alignItems: "baseline", children: [_jsx(Typography, { variant: "h6", component: "span", children: "Shortcuts & gestures" }), !isCoarse && (_jsx(Typography, { variant: "caption", color: "text.secondary", children: "? or Esc to close" }))] }) }), _jsxs(DialogContent, { children: [_jsx(Box, { sx: { - display: "grid", - // A grid rather than a row of columns: the touch group (U-13) makes four, - // and four fixed columns crush the labels on the narrow screens that group - // exists for. auto-fit reflows to one column on a phone by itself. - gridTemplateColumns: { - xs: "1fr", - sm: "repeat(auto-fit, minmax(190px, 1fr))", - }, - columnGap: 4, - rowGap: 2.5, - alignItems: "start", - }, children: sections.map((section) => (_jsxs(Stack, { spacing: 0.75, sx: { minWidth: 0, width: "100%" }, "data-name": `KeyboardSheetGroup-${section.group}`, children: [_jsx(Typography, { variant: "caption", fontWeight: "bold", color: "primary.main", sx: { letterSpacing: "0.06em", textTransform: "uppercase" }, children: section.label }), section.bindings.map((binding) => (_jsx(KeyRow, { label: binding.label, keys: binding.keys }, `${binding.label}-${binding.keys}`)))] }, section.group))) }), _jsx(Divider, { sx: { my: 2 } }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: `The modifier resolves per platform — Ctrl on Windows and Linux, Cmd on macOS; shown above as ${getModifierLabel()}. Edit mode and the measurement modes are mutually exclusive: arming a measurement leaves edit mode, and entering edit mode clears measurements.` })] }), _jsx(DialogActions, { children: _jsx(Button, { onClick: onClose, "data-name": "KeyboardSheetClose", children: "Close" }) })] })); -} -export default KeyboardSheet; diff --git a/dist/components/LoadingIndicator.d.ts b/dist/components/LoadingIndicator.d.ts deleted file mode 100644 index ec117d3f..00000000 --- a/dist/components/LoadingIndicator.d.ts +++ /dev/null @@ -1 +0,0 @@ -export function LoadingIndicator(): import("react/jsx-runtime").JSX.Element; diff --git a/dist/components/LoadingIndicator.js b/dist/components/LoadingIndicator.js deleted file mode 100644 index 6551f16e..00000000 --- a/dist/components/LoadingIndicator.js +++ /dev/null @@ -1,6 +0,0 @@ -import { jsx as _jsx } from "react/jsx-runtime"; -import CircularProgress from "@mui/material/CircularProgress"; -import React from "react"; -export const LoadingIndicator = function LoadingIndicator() { - return (_jsx("div", { className: "spinner-wrap", children: _jsx(CircularProgress, { className: "spinner", color: "secondary" }) })); -}; diff --git a/dist/components/ModalDialog.d.ts b/dist/components/ModalDialog.d.ts deleted file mode 100644 index dd767402..00000000 --- a/dist/components/ModalDialog.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -export class ModalDialog extends React.Component { - constructor(props: any); - onHide(e: any): void; - renderBody(): null; - removeStylingFromBody(): void; - render(): import("react/jsx-runtime").JSX.Element; -} -export namespace ModalDialog { - namespace propTypes { - let modalId: PropTypes.Validator; - let show: PropTypes.Validator; - let onHide: PropTypes.Validator<(...args: any[]) => any>; - let className: PropTypes.Validator; - let isFullWidth: PropTypes.Requireable; - let backdropColor: PropTypes.Requireable; - } - namespace defaultProps { - let isFullWidth_1: boolean; - export { isFullWidth_1 as isFullWidth }; - let backdropColor_1: string; - export { backdropColor_1 as backdropColor }; - } -} -import React from "react"; -import PropTypes from "prop-types"; diff --git a/dist/components/ModalDialog.js b/dist/components/ModalDialog.js deleted file mode 100644 index a6b755ce..00000000 --- a/dist/components/ModalDialog.js +++ /dev/null @@ -1,48 +0,0 @@ -import { jsx as _jsx } from "react/jsx-runtime"; -import Dialog from "@mat3ra/cove/dist/mui/components/dialog/Dialog"; -import PropTypes from "prop-types"; -import React from "react"; -export class ModalDialog extends React.Component { - constructor(props) { - super(props); - this.onHide = this.onHide.bind(this); - this.renderBody = this.renderBody.bind(this); - } - onHide(e) { - const { onHide } = this.props; - onHide(e); - this.removeStylingFromBody(); - } - removeStylingFromBody() { - const { backdropColor } = this.props; - document.body.classList.remove("modal-backdrop-color-" + backdropColor); - } - // eslint-disable-next-line class-methods-use-this - renderBody() { - return null; - } - render() { - const { className, isFullWidth, show, modalId } = this.props; - return (_jsx(Dialog, { id: modalId, animation: false, sx: { height: "100%", width: "100%" }, open: show, fullWidth: isFullWidth, maxWidth: false, onClose: this.onHide, className: className, renderHeaderCustom: () => null, renderFooterCustom: () => null, PaperProps: { - sx: { - maxWidth: "100%", - maxHeight: "100%", - width: "100%", - height: "100%", - m: 0, - }, - }, renderBodyCustom: this.renderBody })); - } -} -ModalDialog.propTypes = { - modalId: PropTypes.string.isRequired, - show: PropTypes.bool.isRequired, - onHide: PropTypes.func.isRequired, - className: PropTypes.string.isRequired, - isFullWidth: PropTypes.bool, - backdropColor: PropTypes.string, -}; -ModalDialog.defaultProps = { - isFullWidth: true, - backdropColor: "white", -}; diff --git a/dist/components/ModePill.d.ts b/dist/components/ModePill.d.ts deleted file mode 100644 index bbf4ebb4..00000000 --- a/dist/components/ModePill.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { MEASUREMENT_MODES_ENUM } from "../enums"; -import { MeasurementSettingsForType } from "../mixins/measurements/MeasurementSettingsHandler"; -/** - * A mode was previously signalled only by the Edit icon changing colour, and a measurement mode - * not at all: it is armed from a dropdown that then closes, after which every click means - * something different with nothing on screen to say so (finding F5). Edit mode additionally - * remaps orbit-rotate to the right mouse button (spec section 3f) - a change to what the primary - * mouse gesture does, advertised nowhere (F4). - * - * This pill names the active mode, states the bindings that appear in no tooltip or menu, and - * offers a one-click exit so leaving a mode does not mean hunting back through the menu that - * armed it. Absence of a pill is itself information: clicks do nothing but orbit. - */ -export interface ModePillProps { - /** Edit mode is active. Mutually exclusive with a measurement, per decision D-12. */ - isEditModeActive?: boolean; - /** The armed measurement mode, or null. */ - activeMeasurement?: MeasurementSettingsForType | null; - /** Exits edit mode. */ - onExitEditMode?: () => void; - /** Exits the given measurement mode. */ - onExitMeasurement?: (measurementType: MEASUREMENT_MODES_ENUM) => void; - /** Whether orbit controls are on, which decides what the right button is said to do. */ - isOrbitEnabled?: boolean; -} -/** - * Bindings worth stating in the pill, sourced from settings so a rebind cannot desync them. - * - * `isOrbitEnabled` gates the orbit note. Orbit controls start disabled - * (`initOrbitControls(enabled = false)`), so while they are off the right button orbits nothing - - * and advertising a binding that does nothing is the defect this whole slice is trying to undo. - * - * `isCoarsePointer` decides *which* orbit gesture is named. A phone has no right button, and edit - * mode reserves one finger for atoms exactly as it frees the left button, so there the camera is on - * two fingers (U-13). Naming the mouse binding on a touch device would be the same class of lie. - */ -export declare function getEditModeBindings({ isOrbitEnabled, isCoarsePointer, }?: { - isOrbitEnabled?: boolean; - isCoarsePointer?: boolean; -}): string[]; -declare function ModePill(props: ModePillProps): import("react/jsx-runtime").JSX.Element | null; -export default ModePill; diff --git a/dist/components/ModePill.js b/dist/components/ModePill.js deleted file mode 100644 index 2eda0405..00000000 --- a/dist/components/ModePill.js +++ /dev/null @@ -1,109 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import Close from "@mui/icons-material/Close"; -import Box from "@mui/material/Box"; -import IconButton from "@mui/material/IconButton"; -import Stack from "@mui/material/Stack"; -import Tooltip from "@mui/material/Tooltip"; -import Typography from "@mui/material/Typography"; -import settings from "../settings"; -import { hasCoarsePointer } from "../utils/inputCapabilities"; -import { formatMeasurementValue, getMeasurementHint, getMeasurementLabel, getMeasurementProgress, } from "../utils/measurementReadout"; -import { useObservedWidth } from "../utils/useObservedWidth"; -import { EDIT_SURFACE_INSET, PILL_COMPACT_WIDTH_PX, SIDE_CHROME_INSET } from "./chromeLayout"; -/** - * Bindings worth stating in the pill, sourced from settings so a rebind cannot desync them. - * - * `isOrbitEnabled` gates the orbit note. Orbit controls start disabled - * (`initOrbitControls(enabled = false)`), so while they are off the right button orbits nothing - - * and advertising a binding that does nothing is the defect this whole slice is trying to undo. - * - * `isCoarsePointer` decides *which* orbit gesture is named. A phone has no right button, and edit - * mode reserves one finger for atoms exactly as it frees the left button, so there the camera is on - * two fingers (U-13). Naming the mouse binding on a touch device would be the same class of lie. - */ -export function getEditModeBindings({ isOrbitEnabled = false, isCoarsePointer = hasCoarsePointer(), } = {}) { - var _a; - const keys = settings.hotKeysConfig; - const orbitKey = (_a = keys === null || keys === void 0 ? void 0 : keys.toggleOrbitControls) === null || _a === void 0 ? void 0 : _a.toUpperCase(); - // The remap that surprises people: in edit mode a left-drag on empty space marquees, so orbit - // moves to the right button (D-4) - or to two fingers on touch. That is only true once orbit is - // on, so while it is off the pill points at the way to turn it on instead. - let orbitNote = ""; - if (isOrbitEnabled) - orbitNote = isCoarsePointer ? "2 fingers = orbit" : "RMB = orbit"; - else if (isCoarsePointer) - orbitNote = "Rotate/Zoom off"; - else if (orbitKey) - orbitNote = `${orbitKey} = enable orbit`; - // Keyboard rows are dropped on a coarse pointer: Del and Esc name keys a phone does not have, - // and the toolbar's Remove button is the reachable equivalent. - const keyboardNotes = isCoarsePointer - ? [] - : [ - "Del = remove", - "Esc = deselect", - (keys === null || keys === void 0 ? void 0 : keys.focusCameraOnSelection) - ? `${keys.focusCameraOnSelection.toUpperCase()} = focus` - : "", - ]; - return [ - isCoarsePointer ? "drag atom = move" : "drag = move", - orbitNote, - ...keyboardNotes, - ].filter(Boolean); -} -function Pill({ label, accent, children, onExit, exitTitle, dataName }) { - return (_jsxs(Stack, { "data-name": dataName, direction: "row", alignItems: "center", spacing: 1, sx: { - pointerEvents: "auto", - pl: 0.75, - pr: onExit ? 0.25 : 1.25, - py: 0.25, - borderRadius: "17px", - border: 1, - borderColor: `${accent}.main`, - backgroundColor: "background.paper", - maxWidth: "100%", - }, children: [_jsx(Box, { sx: { - px: 0.75, - borderRadius: "9px", - backgroundColor: `${accent}.main`, - flexShrink: 0, - }, children: _jsx(Typography, { variant: "caption", fontWeight: "bold", sx: { color: `${accent}.contrastText`, letterSpacing: "0.04em" }, children: label }) }), children, onExit && (_jsx(Tooltip, { title: exitTitle, children: _jsx(IconButton, { size: "small", "aria-label": exitTitle, onClick: onExit, - // A bare IconButton, not a SquareIconButton, so it needs the same explicit - // focus ring rather than relying on MUI's ripple (F9). - sx: { - "&:focus-visible": { - outline: (theme) => `2px solid ${theme.palette[accent].main}`, - outlineOffset: "1px", - }, - }, children: _jsx(Close, { fontSize: "inherit" }) }) }))] })); -} -function ModePill(props) { - const { isEditModeActive = false, activeMeasurement = null, onExitEditMode, onExitMeasurement, isOrbitEnabled = false, } = props; - const { ref: containerRef, width: containerWidth } = useObservedWidth(); - if (!isEditModeActive && !(activeMeasurement === null || activeMeasurement === void 0 ? void 0 : activeMeasurement.isActive)) - return null; - const progress = getMeasurementProgress(activeMeasurement); - const latestValue = formatMeasurementValue(activeMeasurement); - // Until measured, assume there is room: the wide case is the common one, and a pill that starts - // compact and expands one frame later flickers. - const isCompact = containerWidth !== null && containerWidth < PILL_COMPACT_WIDTH_PX; - return (_jsxs(Stack, { ref: containerRef, "data-name": "ModePillContainer", "data-compact": isCompact ? "true" : "false", - // Left-aligned when compact: the top-right corner belongs to the selection inspector, and - // a centred pill lands squarely on it in an embedded panel. - alignItems: isCompact ? "flex-start" : "center", spacing: 0.5, sx: { - position: "absolute", - top: "1em", - // Insets clear the chrome pinned to either edge - the icon strip on the left, the edit - // toolbar and inspector on the right. Spanning the full width let the pill grow to - // 520 px and run underneath all of them, which an embedded viewer showed first. - left: SIDE_CHROME_INSET, - right: isEditModeActive ? EDIT_SURFACE_INSET : SIDE_CHROME_INSET, - // The canvas keeps its pointer events; each pill opts back in for itself. - pointerEvents: "none", - zIndex: 1, - }, children: [isEditModeActive && (_jsx(Pill, { dataName: "ModePill-edit", label: "EDIT", accent: "primary", onExit: onExitEditMode, exitTitle: "Exit edit mode", children: !isCompact && (_jsx(Typography, { variant: "caption", noWrap: true, children: getEditModeBindings({ isOrbitEnabled }).join(" · ") })) })), (activeMeasurement === null || activeMeasurement === void 0 ? void 0 : activeMeasurement.isActive) && (_jsxs(Pill, { dataName: `ModePill-${activeMeasurement.measurementType}`, label: getMeasurementLabel(activeMeasurement.measurementType).toUpperCase(), accent: "warning", onExit: onExitMeasurement - ? () => onExitMeasurement(activeMeasurement.measurementType) - : undefined, exitTitle: `Exit ${getMeasurementLabel(activeMeasurement.measurementType).toLowerCase()} mode`, children: [_jsx(Typography, { variant: "caption", noWrap: true, sx: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" }, children: getMeasurementHint(activeMeasurement) }), progress.isPartial && (_jsx(Typography, { variant: "caption", fontWeight: "bold", noWrap: true, "data-name": "ModePillProgress", children: `${progress.picked} of ${progress.needed} picked` })), latestValue && (_jsx(Typography, { variant: "caption", color: "warning.main", noWrap: true, children: latestValue }))] }))] })); -} -export default ModePill; diff --git a/dist/components/ParametersMenu.d.ts b/dist/components/ParametersMenu.d.ts deleted file mode 100644 index 3cca9450..00000000 --- a/dist/components/ParametersMenu.d.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Viewer parameters. - * - * These were five bare number inputs (finding F7). Their ranges existed only as invisible - * `inputProps`, there was no way back to a default, and nothing said what a value would cost - a - * 4x4x4 repetition on a 24-atom cell draws 1,536 atoms with no warning. Radius and bond cutoff in - * particular are found by nudging until the picture reads right, which is a job for a slider; the - * numeric box stays beside each one so a value can still be read off and reproduced exactly. - */ -export type ViewerSettings = { - isViewAdjustable: boolean; - atomRadiiScale: number; - repetitionsAlongLatticeVectorA: number; - repetitionsAlongLatticeVectorB: number; - repetitionsAlongLatticeVectorC: number; - chemicalConnectivityFactor: number; -}; -type PartialViewerSettings = Partial; -interface ParametersMenuProps { - viewerSettings: ViewerSettings; - /** Atoms in the unit cell, so the repetition cost can be stated rather than guessed. */ - atomCountInCell?: number; - onSettingChange: (setting: PartialViewerSettings) => void; -} -export declare const PARAMETER_RANGES: { - atomRadiiScale: { - min: number; - max: number; - step: number; - }; - chemicalConnectivityFactor: { - min: number; - max: number; - step: number; - }; - repetitions: { - min: number; - max: number; - step: number; - }; -}; -/** Defaults come from settings, so "reset" cannot drift from what the viewer actually starts with. */ -export declare function getParameterDefaults(): PartialViewerSettings; -/** How many atoms a repetition actually draws - the cost the old UI never mentioned. */ -export declare function getDrawnAtomCount(viewerSettings: Pick, atomCountInCell?: number): number; -/** - * Clamps whichever of these settings a patch carries, leaving everything else untouched. - * - * The menu clamps what a user types, but values also arrive from outside it - URL parameters - * (`utils/viewSettingsUrl.ts`) and a host's `initialViewSettings` - and those were never checked. - * That is how an out-of-range value gets in: a saved link with `atomRadiiScale=3` renders at 3, shows - * "3.00" in the field, pins the slider at its maximum, and then silently drops to the maximum the - * first time the slider is touched. Narrowing the radius range from 10 to 1 turned that from a corner - * case into a likely one, so the boundary is worth guarding rather than the control alone. - */ -export declare function clampParameterSettings(partialSettings: PartialViewerSettings): PartialViewerSettings; -declare function ParametersMenu(props: ParametersMenuProps): import("react/jsx-runtime").JSX.Element; -export default ParametersMenu; diff --git a/dist/components/ParametersMenu.js b/dist/components/ParametersMenu.js deleted file mode 100644 index b58f7e8e..00000000 --- a/dist/components/ParametersMenu.js +++ /dev/null @@ -1,119 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import LinkIcon from "@mui/icons-material/Link"; -import LinkOffIcon from "@mui/icons-material/LinkOff"; -import RestartAltIcon from "@mui/icons-material/RestartAlt"; -import Button from "@mui/material/Button"; -import IconButton from "@mui/material/IconButton"; -import Slider from "@mui/material/Slider"; -import Stack from "@mui/material/Stack"; -import TextField from "@mui/material/TextField"; -import Tooltip from "@mui/material/Tooltip"; -import Typography from "@mui/material/Typography"; -import { useState } from "react"; -import settings from "../settings"; -const REPETITION_AXES = ["A", "B", "C"]; -const repetitionKey = (axis) => `repetitionsAlongLatticeVector${axis}`; -export const PARAMETER_RANGES = { - // Capped at 1 rather than 10: the scale multiplies each element's van der Waals radius, so 1 is - // already space-filling and everything above it is atoms swallowing the cell. A range whose top - // 90% is unusable also makes the useful band - around the 0.2 default - a few pixels of travel. - atomRadiiScale: { min: 0.1, max: 1, step: 0.05 }, - chemicalConnectivityFactor: { min: 0, max: 2, step: 0.01 }, - repetitions: { min: 1, max: 10, step: 1 }, -}; -/** Defaults come from settings, so "reset" cannot drift from what the viewer actually starts with. */ -export function getParameterDefaults() { - return { - atomRadiiScale: settings.atomRadiiScale, - chemicalConnectivityFactor: settings.chemicalConnectivityFactor, - repetitionsAlongLatticeVectorA: settings.repetitions, - repetitionsAlongLatticeVectorB: settings.repetitions, - repetitionsAlongLatticeVectorC: settings.repetitions, - }; -} -/** How many atoms a repetition actually draws - the cost the old UI never mentioned. */ -export function getDrawnAtomCount(viewerSettings, atomCountInCell = 0) { - const product = (viewerSettings.repetitionsAlongLatticeVectorA || 1) * - (viewerSettings.repetitionsAlongLatticeVectorB || 1) * - (viewerSettings.repetitionsAlongLatticeVectorC || 1); - return product * atomCountInCell; -} -function clampToRange(value, { min, max }) { - if (Number.isNaN(value)) - return min; - return Math.min(max, Math.max(min, value)); -} -/** Which range governs each viewer setting the menu owns. Repetitions share one range. */ -const RANGE_BY_SETTING = { - atomRadiiScale: PARAMETER_RANGES.atomRadiiScale, - chemicalConnectivityFactor: PARAMETER_RANGES.chemicalConnectivityFactor, - repetitionsAlongLatticeVectorA: PARAMETER_RANGES.repetitions, - repetitionsAlongLatticeVectorB: PARAMETER_RANGES.repetitions, - repetitionsAlongLatticeVectorC: PARAMETER_RANGES.repetitions, -}; -/** - * Clamps whichever of these settings a patch carries, leaving everything else untouched. - * - * The menu clamps what a user types, but values also arrive from outside it - URL parameters - * (`utils/viewSettingsUrl.ts`) and a host's `initialViewSettings` - and those were never checked. - * That is how an out-of-range value gets in: a saved link with `atomRadiiScale=3` renders at 3, shows - * "3.00" in the field, pins the slider at its maximum, and then silently drops to the maximum the - * first time the slider is touched. Narrowing the radius range from 10 to 1 turned that from a corner - * case into a likely one, so the boundary is worth guarding rather than the control alone. - */ -export function clampParameterSettings(partialSettings) { - const clamped = { ...partialSettings }; - Object.entries(RANGE_BY_SETTING).forEach(([key, range]) => { - const value = clamped[key]; - if (typeof value === "number") - clamped[key] = clampToRange(value, range); - }); - return clamped; -} -function ResetButton({ title, onClick }) { - return (_jsx(Tooltip, { title: title, disableInteractive: true, children: _jsx(IconButton, { size: "small", "aria-label": title, onClick: onClick, sx: { - "&:focus-visible": { - outline: (theme) => `2px solid ${theme.palette.primary.main}`, - outlineOffset: "1px", - }, - }, children: _jsx(RestartAltIcon, { fontSize: "inherit" }) }) })); -} -function SliderRow(props) { - const { label, settingKey, value, caption, decimals, onSettingChange } = props; - const range = PARAMETER_RANGES[settingKey]; - return (_jsxs(Stack, { spacing: 0.25, "data-name": `Parameter-${settingKey}`, children: [_jsxs(Stack, { direction: "row", alignItems: "center", justifyContent: "space-between", children: [_jsx(Typography, { variant: "body2", children: label }), _jsx(ResetButton, { title: `Reset ${label.toLowerCase()}`, onClick: () => onSettingChange({ [settingKey]: getParameterDefaults()[settingKey] }) })] }), _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1.5, children: [_jsx(Slider, { size: "small", value: value, min: range.min, max: range.max, step: range.step, "aria-label": label, "data-name": `ParameterSlider-${settingKey}`, onChange: (event, next) => onSettingChange({ [settingKey]: Array.isArray(next) ? next[0] : next }), sx: { flex: 1, minWidth: 0 } }), _jsx(TextField, { size: "small", type: "number", label: "Value", className: "inverse stepper", id: settingKey, value: Number(value).toFixed(decimals), inputProps: { ...range, "aria-label": `${label} value` }, sx: { width: "6em", flexShrink: 0 }, onChange: (event) => onSettingChange({ - [settingKey]: clampToRange(parseFloat(event.target.value), range), - }) })] }), _jsx(Typography, { variant: "caption", color: "text.secondary", noWrap: true, children: `${range.min}–${range.max} · ${caption}` })] })); -} -function ParametersMenu(props) { - const { viewerSettings, atomCountInCell = 0, onSettingChange } = props; - // A=B=C is the common case for a supercell, so linking is on by default but stays overridable. - const [isRepetitionLinked, setIsRepetitionLinked] = useState(true); - const range = PARAMETER_RANGES.repetitions; - const drawnAtoms = getDrawnAtomCount(viewerSettings, atomCountInCell); - const changeRepetition = (axis, rawValue) => { - const value = clampToRange(parseFloat(rawValue), range); - if (!isRepetitionLinked) { - onSettingChange({ [repetitionKey(axis)]: value }); - return; - } - onSettingChange({ - repetitionsAlongLatticeVectorA: value, - repetitionsAlongLatticeVectorB: value, - repetitionsAlongLatticeVectorC: value, - }); - }; - return (_jsxs(Stack, { spacing: 2, margin: 2, sx: { width: "22em" }, "data-name": "ParametersMenu", children: [_jsx(SliderRow, { label: "Atomic radius", settingKey: "atomRadiiScale", value: viewerSettings.atomRadiiScale, decimals: 2, caption: "1 = full van der Waals size", onSettingChange: onSettingChange }), _jsxs(Stack, { spacing: 0.5, "data-name": "Parameter-repetitions", children: [_jsxs(Stack, { direction: "row", alignItems: "center", justifyContent: "space-between", children: [_jsx(Typography, { variant: "body2", children: "Cell repetitions" }), _jsxs(Stack, { direction: "row", alignItems: "center", children: [_jsx(Tooltip, { title: isRepetitionLinked - ? "A, B and C change together — click to set them separately" - : "A, B and C change separately — click to link them", disableInteractive: true, children: _jsx(IconButton, { size: "small", "aria-label": "Link repetitions", "aria-pressed": isRepetitionLinked, "data-name": "RepetitionLink", "data-active": isRepetitionLinked ? "true" : "false", onClick: () => setIsRepetitionLinked(!isRepetitionLinked), color: isRepetitionLinked ? "primary" : "default", sx: { - "&:focus-visible": { - outline: (theme) => `2px solid ${theme.palette.primary.main}`, - outlineOffset: "1px", - }, - }, children: isRepetitionLinked ? (_jsx(LinkIcon, { fontSize: "inherit" })) : (_jsx(LinkOffIcon, { fontSize: "inherit" })) }) }), _jsx(ResetButton, { title: "Reset repetitions", onClick: () => onSettingChange({ - repetitionsAlongLatticeVectorA: settings.repetitions, - repetitionsAlongLatticeVectorB: settings.repetitions, - repetitionsAlongLatticeVectorC: settings.repetitions, - }) })] })] }), _jsx(Stack, { direction: "row", spacing: 1, children: REPETITION_AXES.map((axis) => (_jsx(TextField, { label: axis, size: "small", type: "number", className: "inverse stepper cell-repetitions", id: `repetitionsAlongLatticeVector${axis}`, value: viewerSettings[repetitionKey(axis)], inputProps: { ...range, "aria-label": `Repetitions along ${axis}` }, sx: { flex: 1, minWidth: 0 }, onChange: (event) => changeRepetition(axis, event.target.value) }, axis))) }), Boolean(atomCountInCell) && (_jsx(Typography, { variant: "caption", color: drawnAtoms > 2000 ? "warning.main" : "text.secondary", "data-name": "RepetitionCost", children: `${viewerSettings.repetitionsAlongLatticeVectorA} × ${viewerSettings.repetitionsAlongLatticeVectorB} × ${viewerSettings.repetitionsAlongLatticeVectorC} → ${drawnAtoms.toLocaleString()} atoms` }))] }), _jsx(SliderRow, { label: "Bond cutoff", settingKey: "chemicalConnectivityFactor", value: viewerSettings.chemicalConnectivityFactor, decimals: 2, caption: "\u00D7 the two atoms' van der Waals sum", onSettingChange: onSettingChange }), _jsx(Button, { size: "small", variant: "text", startIcon: _jsx(RestartAltIcon, {}), "data-name": "ResetAllParameters", sx: { alignSelf: "flex-start", textTransform: "none" }, onClick: () => onSettingChange(getParameterDefaults()), children: "Reset all" })] })); -} -export default ParametersMenu; diff --git a/dist/components/QuickToggles.d.ts b/dist/components/QuickToggles.d.ts deleted file mode 100644 index 770c6caa..00000000 --- a/dist/components/QuickToggles.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -import React from "react"; -/** - * The handful of view toggles worth reaching in one click. - * - * Bonds, labels, axes and the camera projection all live inside the View dropdown, which closes on - * every choice - so flipping bonds while comparing two structures is a four-click round trip - * through a menu that shuts behind you. These are the items people toggle repeatedly rather than - * set once, promoted out of the menu (U-7). The menu keeps them too; this is a shortcut, not a - * move, so nothing a user already knows stops working. - * - * Active state is a filled button, which is the same correction U-4 made in the menu: the previous - * rendering used one shape - a checkmark - for both on and off, separated only by colour. - */ -export interface QuickToggleItem { - id: string; - /** Tooltip text; the hotkey is appended when there is one. */ - title: string; - hotKey?: string; - isActive?: boolean; - icon: React.ReactNode; - onToggle: () => void; -} -export interface QuickTogglesProps { - items?: QuickToggleItem[]; -} -declare function QuickToggles({ items }: QuickTogglesProps): import("react/jsx-runtime").JSX.Element | null; -export default QuickToggles; diff --git a/dist/components/QuickToggles.js b/dist/components/QuickToggles.js deleted file mode 100644 index e4f47cea..00000000 --- a/dist/components/QuickToggles.js +++ /dev/null @@ -1,61 +0,0 @@ -import { jsx as _jsx } from "react/jsx-runtime"; -import Box from "@mui/material/Box"; -import Paper from "@mui/material/Paper"; -import Stack from "@mui/material/Stack"; -import Tooltip from "@mui/material/Tooltip"; -import { COARSE_POINTER_QUERY, TOUCH_TARGET_MIN_PX } from "../utils/inputCapabilities"; -function QuickToggles({ items = [] }) { - if (!items.length) - return null; - return (_jsx(Stack, { "data-name": "QuickToggles", direction: "row", justifyContent: "center", sx: { - position: "absolute", - left: 0, - right: 0, - // Clears the status bar; the edit surface above is top-aligned, so nothing overlaps. - bottom: "3em", - pointerEvents: "none", - zIndex: 1, - }, children: _jsx(Paper, { elevation: 2, sx: { - pointerEvents: "auto", - borderRadius: "20px", - px: 0.75, - py: 0.5, - boxShadow: 4, - }, children: _jsx(Stack, { direction: "row", spacing: 0.5, children: items.map((item) => (_jsx(Tooltip, { title: item.hotKey - ? `${item.title} [${item.hotKey.toUpperCase()}]` - : item.title, disableInteractive: true, children: _jsx(Box, { component: "button", type: "button", "aria-pressed": Boolean(item.isActive), "aria-label": item.title, "data-name": `QuickToggle-${item.id}`, "data-active": item.isActive ? "true" : "false", onClick: item.onToggle, sx: { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: 32, - height: 32, - p: 0, - borderRadius: "16px", - // Grown to the touch minimum on coarse pointers only (U-13): a - // 32 px circle is a comfortable mouse target and an unreliable - // finger one, and this row is the primary control surface on a - // phone, where the toolbar menus are the awkward path. - [`@media ${COARSE_POINTER_QUERY}`]: { - width: TOUCH_TARGET_MIN_PX, - height: TOUCH_TARGET_MIN_PX, - borderRadius: `${TOUCH_TARGET_MIN_PX / 2}px`, - "& svg": { fontSize: "1.35rem" }, - }, - cursor: "pointer", - border: 1, - borderColor: item.isActive ? "primary.light" : "divider", - backgroundColor: item.isActive ? "primary.main" : "transparent", - color: item.isActive - ? "primary.contrastText" - : "text.secondary", - "& svg": { fontSize: "1.1rem" }, - "&:hover": { - borderColor: "primary.light", - }, - "&:focus-visible": { - outline: (theme) => `2px solid ${theme.palette.primary.main}`, - outlineOffset: "2px", - }, - }, children: item.icon }) }, item.id))) }) }) })); -} -export default QuickToggles; diff --git a/dist/components/SelectionInspector.d.ts b/dist/components/SelectionInspector.d.ts deleted file mode 100644 index 254b5b45..00000000 --- a/dist/components/SelectionInspector.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * The per-selection data, as a card rather than more buttons. - * - * Split out of the 84 px icon column (finding F1), where a value like `-0.083` had to fit a - * `small` MUI field 84 px wide with a floating label, and where the fields sat below eight icon - * buttons in a stack ~600 px tall with no scroll - so on a short viewer they were cut off and - * unreachable. Here the coordinates sit side by side with room to be read, and the card scrolls - * inside its own bounds rather than overflowing the canvas. - * - * Empty state included on purpose: the panel used to render nothing at all when no atom was - * selected, which taught the user nothing about how to select one - and the selection modifiers - * (Shift-click, marquee) appear in no other UI. - */ -export type DisplayUnits = "crystal" | "cartesian"; -export interface SelectionInspectorProps { - /** Selected atomic indices, per the mixin's multi-select contract. */ - selectedAtomIndices?: number[]; - /** Element symbol when exactly one atom is selected. */ - selectedElement?: string; - /** Coordinates of the single selected atom, in the material's own units. */ - selectedCoordinates?: number[]; - /** The material's own basis units - what the coordinates above are expressed in. */ - materialUnits?: string; - /** Units currently being displayed; may differ from the material's own. */ - displayUnits?: DisplayUnits; - /** Coordinates converted into `displayUnits`, when that differs from `materialUnits`. */ - displayCoordinates?: number[] | null; - /** CSS colour for the element swatch. */ - elementColor?: string; - /** Draft strings while a coordinate field is focused; null means "show the committed value". */ - coordinateDrafts?: (string | null)[]; - /** Draft string while the element field is focused. */ - elementDraft?: string | null; - onDisplayUnitsChange?: (units: DisplayUnits) => void; - onCoordinateDraftChange?: (axisIndex: number, value: string) => void; - onCoordinateCommit?: (axisIndex: number) => void; - onElementDraftChange?: (value: string) => void; - onElementCommit?: () => void; -} -/** Caption for a units value, matching what the edit panel used to show. */ -export declare function getUnitsCaption(units?: string): string; -declare function SelectionInspector(props: SelectionInspectorProps): import("react/jsx-runtime").JSX.Element; -export default SelectionInspector; diff --git a/dist/components/SelectionInspector.js b/dist/components/SelectionInspector.js deleted file mode 100644 index a9d11477..00000000 --- a/dist/components/SelectionInspector.js +++ /dev/null @@ -1,80 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import Box from "@mui/material/Box"; -import Divider from "@mui/material/Divider"; -import Paper from "@mui/material/Paper"; -import Stack from "@mui/material/Stack"; -import TextField from "@mui/material/TextField"; -import ToggleButton from "@mui/material/ToggleButton"; -import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; -import Typography from "@mui/material/Typography"; -import settings from "../settings"; -import { INSPECTOR_WIDTH } from "./chromeLayout"; -const AXIS_NAMES = ["X", "Y", "Z"]; -/** Caption for a units value, matching what the edit panel used to show. */ -export function getUnitsCaption(units) { - return units === "cartesian" ? "cartesian, Å" : "crystal"; -} -function SelectionInspector(props) { - const { selectedAtomIndices = [], selectedElement = "", selectedCoordinates = [0, 0, 0], materialUnits = "crystal", displayUnits, displayCoordinates = null, elementColor = settings.defaultColor, coordinateDrafts = [null, null, null], elementDraft = null, onDisplayUnitsChange, onCoordinateDraftChange, onCoordinateCommit, onElementDraftChange, onElementCommit, } = props; - const nativeUnits = materialUnits === "cartesian" ? "cartesian" : "crystal"; - const shownUnits = displayUnits || nativeUnits; - // Editing writes straight into the basis in its own units. Converting a whole point back on - // commit is a separate, riskier change than switching what is displayed, so while a - // non-native unit is shown the fields are read-only and say why. - const isEditable = shownUnits === nativeUnits; - const coordinates = (isEditable ? selectedCoordinates : displayCoordinates) || [0, 0, 0]; - const isSingleAtomSelected = selectedAtomIndices.length === 1; - const isGroupSelected = selectedAtomIndices.length > 1; - return (_jsxs(Paper, { elevation: 2, "data-name": "SelectionInspector", sx: { - boxShadow: 4, - width: INSPECTOR_WIDTH, - maxHeight: "100%", - overflowY: "auto", - p: 1.25, - }, children: [_jsx(Typography, { variant: "caption", fontWeight: "bold", color: "text.secondary", sx: { letterSpacing: "0.06em" }, children: "SELECTION" }), !isSingleAtomSelected && !isGroupSelected && (_jsxs(Stack, { spacing: 0.5, sx: { mt: 1 }, "data-name": "SelectionInspectorEmpty", children: [_jsx(Typography, { variant: "caption", children: "Click an atom to select it." }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: "Shift-click adds to the selection; drag empty space to marquee-select." })] })), isGroupSelected && (_jsxs(Stack, { spacing: 0.5, sx: { mt: 1 }, "data-name": "SelectionInspectorGroup", children: [_jsx(Typography, { variant: "caption", fontWeight: "bold", children: `${selectedAtomIndices.length} atoms selected` }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: "Drag, or use the gizmo, to move or rotate the group together." })] })), isSingleAtomSelected && (_jsxs(Stack, { spacing: 1, sx: { mt: 1 }, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Box, { sx: { - width: 14, - height: 14, - borderRadius: "50%", - backgroundColor: elementColor, - flexShrink: 0, - } }), _jsx(TextField, { label: "Element", size: "small", type: "text", className: "inverse stepper", sx: { width: "5.5em" }, value: elementDraft !== null ? elementDraft : selectedElement || "Si", onChange: (event) => onElementDraftChange === null || onElementDraftChange === void 0 ? void 0 : onElementDraftChange(event.target.value), onBlur: () => onElementCommit === null || onElementCommit === void 0 ? void 0 : onElementCommit(), onKeyDown: (event) => { - if (event.key === "Enter") { - event.target.blur(); - } - } }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: `site ${selectedAtomIndices[0]}` })] }), _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: "Units" }), _jsxs(ToggleButtonGroup, { size: "small", exclusive: true, value: shownUnits, onChange: (event, value) => { - if (value) - onDisplayUnitsChange === null || onDisplayUnitsChange === void 0 ? void 0 : onDisplayUnitsChange(value); - }, "data-name": "SelectionInspectorUnits", children: [_jsx(ToggleButton, { value: "crystal", sx: { py: 0, textTransform: "none" }, children: "crystal" }), _jsx(ToggleButton, { value: "cartesian", sx: { py: 0, textTransform: "none" }, children: "cartesian" })] })] }), _jsx(Box, { "data-name": "SelectionInspectorCoordinates", sx: { - display: "grid", - gridTemplateColumns: "repeat(3, minmax(0, 1fr))", - gap: 0.75, - }, children: AXIS_NAMES.map((axisName, axisIndex) => { - const draftValue = coordinateDrafts[axisIndex]; - const committedValue = coordinates[axisIndex] !== undefined - ? coordinates[axisIndex].toFixed(settings.roundPrecision) - : "0"; - return (_jsx(TextField, { label: axisName, size: "small", - // type="text" (not "number"): a native number input silently - // drops a lone "-" or an empty string instead of firing - // onChange, which makes typing a negative coordinate or - // clearing the field to retype impossible (D18). - type: "text", inputMode: "decimal", className: "inverse stepper", - // MUI's InputBase root carries min-width: 75px, which is - // what pushed the third field past the card edge regardless - // of how wide its grid column was. Matched by class substring - // because MuiClassNameSetup renames MUI classes to - // "wave-Mui*", so ".MuiInputBase-root" would never match. - sx: { - minWidth: 0, - '& [class*="InputBase-root"]': { minWidth: 0 }, - "& input": { minWidth: 0, px: 0.75 }, - }, disabled: !isEditable, value: draftValue !== null && isEditable - ? draftValue - : committedValue, onChange: (event) => onCoordinateDraftChange === null || onCoordinateDraftChange === void 0 ? void 0 : onCoordinateDraftChange(axisIndex, event.target.value), onBlur: () => onCoordinateCommit === null || onCoordinateCommit === void 0 ? void 0 : onCoordinateCommit(axisIndex), onKeyDown: (event) => { - if (event.key === "Enter") { - event.target.blur(); - } - } }, axisName)); - }) }), !isEditable && (_jsx(Typography, { variant: "caption", color: "warning.main", "data-name": "SelectionInspectorReadOnlyNote", children: `Showing ${getUnitsCaption(shownUnits)}. Switch to ${getUnitsCaption(nativeUnits)} to edit — that is what this material stores.` })), _jsx(Divider, {}), _jsx(Typography, { variant: "caption", color: "text.secondary", children: `Stored as ${getUnitsCaption(nativeUnits)}` })] }))] })); -} -export default SelectionInspector; diff --git a/dist/components/ShowIf.d.ts b/dist/components/ShowIf.d.ts deleted file mode 100644 index 1c159d72..00000000 --- a/dist/components/ShowIf.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Renders children depending on a Boolean condition - * @property {boolean} condition The condition - * @property {node} children Children element that are required for this component - */ -export class ShowIf extends React.Component { - constructor(props: any); - constructor(props: any, context: any); - render(): any; -} -export namespace ShowIf { - namespace propTypes { - let condition: PropTypes.Validator; - let children: PropTypes.Validator>; - } -} -import React from "react"; -import PropTypes from "prop-types"; diff --git a/dist/components/ShowIf.js b/dist/components/ShowIf.js deleted file mode 100644 index f321b0a2..00000000 --- a/dist/components/ShowIf.js +++ /dev/null @@ -1,18 +0,0 @@ -import PropTypes from "prop-types"; -import React from "react"; -/** - * Renders children depending on a Boolean condition - * @property {boolean} condition The condition - * @property {node} children Children element that are required for this component - */ -class ShowIf extends React.Component { - render() { - const { condition, children } = this.props; - return condition ? children : null; - } -} -ShowIf.propTypes = { - condition: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired, -}; -export { ShowIf }; diff --git a/dist/components/SquareIconButton.d.ts b/dist/components/SquareIconButton.d.ts deleted file mode 100644 index ae849bf4..00000000 --- a/dist/components/SquareIconButton.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IconButtonProps } from "@mui/material/IconButton"; -import { TooltipProps } from "@mui/material/Tooltip"; -import React from "react"; -interface SquareIconButtonProps extends IconButtonProps { - title: string; - id?: string; - label?: string; - onClick: (...args: React.MouseEvent[]) => void; - tooltipPlacement?: TooltipProps["placement"]; - isToggleable?: boolean; - isToggled?: boolean; -} -/** - * Square icon button with toggle logic - */ -declare function SquareIconButton(props: SquareIconButtonProps): import("react/jsx-runtime").JSX.Element; -export default SquareIconButton; diff --git a/dist/components/SquareIconButton.js b/dist/components/SquareIconButton.js deleted file mode 100644 index c0f6e5c9..00000000 --- a/dist/components/SquareIconButton.js +++ /dev/null @@ -1,34 +0,0 @@ -import { jsx as _jsx } from "react/jsx-runtime"; -import IconButton from "@mui/material/IconButton"; -import Tooltip from "@mui/material/Tooltip"; -/** - * Square icon button with toggle logic - */ -function SquareIconButton(props) { - const { title, id, label, onClick, tooltipPlacement = "top", disabled } = props; - // Everything this component consumes itself is stripped out; the remainder - `disabled` - // included, which is read above but still forwarded - passes through to IconButton. - const { title: consumedTitle, tooltipPlacement: consumedTooltipPlacement, id: consumedId, label: consumedLabel, onClick: consumedOnClick, isToggleable, isToggled, sx: callerSx, ...iconButtonProps } = props; - /** - * `disableFocusRipple` removes MUI's only focus affordance, and neither MUI nor the browser - * leaves an outline on a ButtonBase - measured on the running app, a keyboard-focused toolbar - * button had `outline: none`, `box-shadow: none` and a transparent background, so focus was - * completely invisible (finding F9). The ripple is a poor focus indicator anyway (it fades), - * so it stays disabled and an explicit ring takes its place. - * - * `:focus-visible` rather than `:focus`, so a mouse click does not leave a ring behind. - */ - const defaultIconButtonStyle = { - borderRadius: 0, - "&:focus-visible": { - outline: (theme) => `2px solid ${theme.palette.primary.main}`, - outlineOffset: "-2px", - }, - }; - const iconButton = (_jsx(IconButton, { disableFocusRipple: true, disableTouchRipple: true, size: "large", "aria-label": label || title.toLowerCase(), onClick: onClick, - // Caller styles merge on top rather than replacing the defaults: passing `sx` used to - // drop borderRadius (and now the focus ring) entirely, since the spread came after it. - sx: [defaultIconButtonStyle, ...(Array.isArray(callerSx) ? callerSx : [callerSx])], ...iconButtonProps }, id)); - return (_jsx(Tooltip, { id: id, title: title, placement: tooltipPlacement, disableInteractive: true, children: disabled ? _jsx("span", { children: iconButton }) : iconButton })); -} -export default SquareIconButton; diff --git a/dist/components/StatusBar.d.ts b/dist/components/StatusBar.d.ts deleted file mode 100644 index c9a184c5..00000000 --- a/dist/components/StatusBar.d.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Nothing in the viewer used to say what structure was on screen: no formula, no atom count, - * no lattice, and no units except one caption inside the edit panel that only appeared while - * exactly one atom was selected (finding F10). Measurement results were equally invisible - - * a 3D sprite plus a silent clipboard write (F5) - and the element colours that encode - * identity had no key at all (F11). - * - * This bar is a read-only view of state `ThreeDEditor` already holds. It never mutates the - * material, so it cannot perturb the edit path, and its right-hand region doubles as the - * component's only `aria-live` region. - */ -/** A basis element entry is a bare symbol in some fixtures and a `{ value }` cell in others. */ -type ElementEntry = string | { - value?: string; - element?: string; -}; -interface LatticeLike { - type?: string; - unitCell?: Record; -} -interface BasisLike { - elements?: ElementEntry[]; - units?: string; -} -export interface MaterialLike { - formula?: string; - unitCellFormula?: string; - name?: string; - basis?: BasisLike; - getLattice?: () => LatticeLike | undefined; -} -export interface StatusBarProps { - material?: MaterialLike | null; - /** Selected atomic indices, per the mixin's multi-select contract. */ - selectedAtomIndices?: number[]; - /** Symbol of the single selected atom, when there is exactly one. */ - selectedElement?: string; - /** Latest measurement readout, e.g. `"d = 2.351 Å"`. Null hides the slot. */ - measurement?: string | null; - /** Transient description of the edit that just committed, e.g. "Moved atom · Ctrl + Z to undo". */ - lastActionHint?: string | null; - /** Click a composition chip. Omit to render the chips as a plain legend. */ - onSelectElement?: (elementSymbol: string) => void; -} -export declare function normalizeElement(entry: ElementEntry | undefined): string; -/** Element symbols in first-appearance order with their counts - the composition legend. */ -export declare function getComposition(material?: MaterialLike | null): [string, number][]; -/** - * Lattice constants from the cell vectors rather than from a `Lattice` getter: the vectors are - * the one representation every code path here already relies on (`Lattice.unitCell` drives - * add-atom placement and the viewer's own cell object), so this cannot disagree with what is - * drawn. - */ -export declare function getLatticeSummary(material?: MaterialLike | null): string; -/** Splits "Si8O16" into symbol/count pairs so the counts can render as subscripts. */ -export declare function tokenizeFormula(formula: string): [string, string][]; -declare function StatusBar(props: StatusBarProps): import("react/jsx-runtime").JSX.Element; -export default StatusBar; diff --git a/dist/components/StatusBar.js b/dist/components/StatusBar.js deleted file mode 100644 index e0910fb6..00000000 --- a/dist/components/StatusBar.js +++ /dev/null @@ -1,150 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import { useTheme } from "@mui/material/styles"; -import Tooltip from "@mui/material/Tooltip"; -import Typography from "@mui/material/Typography"; -import React, { useMemo } from "react"; -import settings from "../settings"; -export function normalizeElement(entry) { - if (!entry) - return ""; - if (typeof entry === "string") - return entry; - return entry.value || entry.element || ""; -} -/** Element symbols in first-appearance order with their counts - the composition legend. */ -export function getComposition(material) { - var _a; - const elements = (_a = material === null || material === void 0 ? void 0 : material.basis) === null || _a === void 0 ? void 0 : _a.elements; - if (!Array.isArray(elements)) - return []; - const counts = new Map(); - elements.forEach((entry) => { - const symbol = normalizeElement(entry); - if (symbol) - counts.set(symbol, (counts.get(symbol) || 0) + 1); - }); - return Array.from(counts.entries()); -} -const norm = (x, y, z) => Math.sqrt(x * x + y * y + z * z); -/** - * Lattice constants from the cell vectors rather than from a `Lattice` getter: the vectors are - * the one representation every code path here already relies on (`Lattice.unitCell` drives - * add-atom placement and the viewer's own cell object), so this cannot disagree with what is - * drawn. - */ -export function getLatticeSummary(material) { - var _a; - const lattice = (_a = material === null || material === void 0 ? void 0 : material.getLattice) === null || _a === void 0 ? void 0 : _a.call(material); - const cell = lattice === null || lattice === void 0 ? void 0 : lattice.unitCell; - const type = lattice === null || lattice === void 0 ? void 0 : lattice.type; - if (!cell) - return type || ""; - const { ax = 0, ay = 0, az = 0, bx = 0, by = 0, bz = 0, cx = 0, cy = 0, cz = 0 } = cell; - const a = norm(ax, ay, az); - const b = norm(bx, by, bz); - const c = norm(cx, cy, cz); - if (!a && !b && !c) - return type || ""; - const round = (value) => value.toFixed(3); - // Collapse a = b = c to a single figure; showing "5.431 · 5.431 · 5.431" is noise. - const isCubicLength = Math.abs(a - b) < 1e-4 && Math.abs(b - c) < 1e-4; - const lengths = isCubicLength - ? `a = ${round(a)} Å` - : `a, b, c = ${round(a)}, ${round(b)}, ${round(c)} Å`; - return [type, lengths].filter(Boolean).join(" · "); -} -/** Splits "Si8O16" into symbol/count pairs so the counts can render as subscripts. */ -export function tokenizeFormula(formula) { - const tokens = []; - const pattern = /([A-Z][a-z]?)(\d*)/g; - let match = pattern.exec(formula); - while (match) { - if (match[1]) - tokens.push([match[1], match[2] || ""]); - match = pattern.exec(formula); - } - return tokens; -} -/** - * `elementColors` values are CSS strings in the periodic-table package but numeric hex in - * some overrides, so accept both rather than trusting one. - */ -function toCssColor(value) { - if (typeof value === "number") - return `#${value.toString(16).padStart(6, "0")}`; - if (typeof value === "string") - return value.startsWith("#") ? value : `#${value}`; - return settings.defaultColor; -} -function FormulaText({ formula }) { - const tokens = tokenizeFormula(formula); - // A formula the tokenizer cannot read (an empty string, or a name that is not a formula at - // all) still renders verbatim rather than vanishing. - const parts = tokens.length ? tokens : [[formula, ""]]; - return (_jsx(_Fragment, { children: parts.map(([symbol, count], index) => ( - // Position *is* the identity here - a formula like "SiOSi" repeats symbols, so - // there is no stabler key than where the token sits. - // eslint-disable-next-line react/no-array-index-key - _jsxs(React.Fragment, { children: [symbol, count && (_jsx(Box, { component: "sub", sx: { fontSize: "0.72em", lineHeight: 0 }, children: count }))] }, `${symbol}${count}-${index}`))) })); -} -function StatusBar(props) { - var _a, _b, _c; - const { material, selectedAtomIndices = [], selectedElement = "", measurement = null, lastActionHint = null, onSelectElement, } = props; - const theme = useTheme(); - const composition = useMemo(() => getComposition(material), [material]); - const latticeSummary = useMemo(() => getLatticeSummary(material), [material]); - const atomCount = ((_b = (_a = material === null || material === void 0 ? void 0 : material.basis) === null || _a === void 0 ? void 0 : _a.elements) === null || _b === void 0 ? void 0 : _b.length) || 0; - const formula = (material === null || material === void 0 ? void 0 : material.formula) || (material === null || material === void 0 ? void 0 : material.unitCellFormula) || ""; - const units = ((_c = material === null || material === void 0 ? void 0 : material.basis) === null || _c === void 0 ? void 0 : _c.units) === "cartesian" ? "cartesian, Å" : "crystal"; - let selectionText = ""; - if (selectedAtomIndices.length === 1) { - const [index] = selectedAtomIndices; - selectionText = `${selectedElement || "atom"} #${index} selected`; - } - else if (selectedAtomIndices.length > 1) { - selectionText = `${selectedAtomIndices.length} atoms selected`; - } - const facts = [ - atomCount ? `${atomCount} atom${atomCount === 1 ? "" : "s"}` : "", - latticeSummary, - units, - ].filter(Boolean); - return (_jsxs(Stack, { "data-name": "StatusBar", direction: "row", alignItems: "center", spacing: 2, sx: { - position: "absolute", - bottom: 0, - left: 0, - right: 0, - minHeight: "34px", - px: 1.5, - py: 0.5, - backgroundColor: "rgba(24, 24, 24, 0.92)", - borderTop: `1px solid ${theme.palette.divider}`, - // The canvas owns pointer events; only the chips opt back in. - pointerEvents: "none", - overflowX: "auto", - whiteSpace: "nowrap", - }, children: [formula && (_jsx(Typography, { variant: "caption", fontWeight: "bold", "data-name": "StatusBarFormula", children: _jsx(FormulaText, { formula: formula }) })), Boolean(facts.length) && (_jsx(Typography, { variant: "caption", color: "text.secondary", children: facts.join(" · ") })), _jsx(Stack, { direction: "row", spacing: 0.5, sx: { pointerEvents: "auto" }, children: composition.map(([symbol, count]) => { - var _a; - const swatch = toCssColor((_a = settings.elementColors) === null || _a === void 0 ? void 0 : _a[symbol]); - const chip = (_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 0.5, "data-name": `StatusBarChip-${symbol}`, onClick: onSelectElement ? () => onSelectElement(symbol) : undefined, sx: { - px: 0.75, - py: 0.125, - borderRadius: "11px", - border: `1px solid ${theme.palette.divider}`, - cursor: onSelectElement ? "pointer" : "default", - "&:hover": onSelectElement - ? { borderColor: theme.palette.primary.main } - : undefined, - }, children: [_jsx(Box, { sx: { - width: 9, - height: 9, - borderRadius: "50%", - backgroundColor: swatch, - flexShrink: 0, - } }), _jsx(Typography, { variant: "caption", children: `${symbol} ${count}` })] }, symbol)); - return onSelectElement ? (_jsx(Tooltip, { title: `Select all ${count} ${symbol} atoms`, children: chip }, symbol)) : (chip); - }) }), _jsxs(Stack, { direction: "row", spacing: 1.5, sx: { marginLeft: "auto !important" }, "aria-live": "polite", "aria-atomic": "true", "data-name": "StatusBarLive", children: [lastActionHint && (_jsx(Typography, { variant: "caption", color: "primary.main", "data-name": "StatusBarHint", children: lastActionHint })), selectionText && _jsx(Typography, { variant: "caption", children: selectionText }), measurement && (_jsx(Typography, { variant: "caption", color: "warning.main", children: measurement }))] })] })); -} -export default StatusBar; diff --git a/dist/components/ThreeDEditor.d.ts b/dist/components/ThreeDEditor.d.ts deleted file mode 100644 index 75bfe40e..00000000 --- a/dist/components/ThreeDEditor.d.ts +++ /dev/null @@ -1,539 +0,0 @@ -/** - * Wrapper component containing 3D visualization through `WaveComponent` and the associated controls - */ -export class ThreeDEditor extends React.Component { - /** - * Create a ThreeDEditor component - * @param props Properties as explained below - */ - constructor(props: any); - state: { - isInteractive: boolean; - activeToolbarMenu: null; - isEditModeActive: boolean; - activeTransformMode: string; - historyStack: any[]; - historyPointer: number; - selectedAtomIndices: never[]; - coordinateDrafts: null[]; - elementDraft: null; - measurementsSettings: { - isActive: boolean; - measurementType: string; - values: never[]; - }[]; - isKeyboardSheetOpen: boolean; - isFigureExportOpen: boolean; - displayUnits: null; - lastActionHint: null; - viewerError: null; - viewerResetKey: number; - viewerTriggerResize: boolean; - viewerSettings: Partial; - _initialToggleSettings: { - orthographicCamera: any; - bonds: any; - axes: any; - autoRotate: any; - elementLabels: any; - coordinateLabels: any; - }; - boundaryConditions: any; - isConventionalCellShown: any; - originalMaterial: any; - material: any; - }; - handleCellRepetitionsChange(e: any): void; - handleSphereRadiusChange(e: any): void; - handleDownloadClick(format?: string): void; - handleToggleInteractive(): void; - handleToggleToolbarMenu(toolbarMenuName: any): void; - handleToggleBonds(): void; - handleToggleEditMode(): void; - handleToggleOrthographicCamera(): void; - handleToggleElementLabels(): void; - handleToggleCoordinateLabels(): void; - handleToggleConventionalCell(): void; - handleToggleIsViewAdjustable(): void; - handleResetViewer(): void; - handleTakeScreenshot(): void; - handleToggleOrbitControls(): void; - handleToggleOrbitControlsAnimation(): void; - handleToggleAxes(): void; - handleStructureModified(newMaterial: any, source: any): void; - handleUndo(): void; - handleRedo(): void; - /** - * Commits a single axis of the selected atom's coordinate. Only called once per field edit - * (on blur/Enter, via handleCoordinateCommit below) rather than per keystroke, so this is - * naturally one history entry per edit (D18). Mutates a clone's basis in place via - * Basis/setBasis rather than reconstructing via fromElementsAndCoordinates, so labels and - * constraints on every atom (including the one being edited) survive untouched (D7). Only - * meaningful for exactly one selected atom - the coordinate panel itself is hidden for 0 or - * 2+ selected (see renderEditSurface), so this is a defensive guard, not the primary gate. - */ - handleCoordinateChange(axisIndex: any, value: any): void; - /** - * Updates only the local draft string for one coordinate field while it's focused - no - * commit, no history entry, no scene rebuild. Lets the field be cleared or start with "-" - * without the browser's number-input semantics dropping the keystroke (D18). - */ - handleCoordinateDraftChange(axisIndex: any, value: any): void; - /** - * Commits the draft for one coordinate field (on blur/Enter) if it parses to a real number, - * then clears the draft so the field reverts to showing the committed value. - */ - handleCoordinateCommit(axisIndex: any): void; - handleSetTransformMode(mode: any): void; - /** - * Places a new atom at the true center of the cell - (a+b+c)/2, the vector sum of the three - * lattice vectors halved - not the component-wise (ax/2, by/2, cz/2), which lands off-center - * or outside the cell entirely for non-orthogonal lattices (D22). If that position is - * already occupied (e.g. a second click with nothing else changed), nudges the candidate - * along the diagonal until it clears every existing atom by OCCUPIED_TOLERANCE, so repeated - * clicks don't silently stack coincident duplicates. - */ - handleAddAtom(): void; - handleRemoveSelectedAtom(): void; - handleCloneSelectedAtoms(): void; - handleFocusCameraOnSelection(): void; - /** - * Updates only the local draft string for the element field while it's focused - no commit, - * no history entry, no scene rebuild. Mirrors handleCoordinateDraftChange (D18). - */ - handleElementDraftChange(value: any): void; - /** - * Commits the element draft (on blur/Enter) if it names a real element that differs from the - * current one, then clears the draft so the field reverts to showing the committed value. - * Operates directly on state.material like handleCoordinateChange, rather than through the - * mixin - this is a panel-typed edit, not a scene-gesture-driven one. Symbol casing is - * normalized (e.g. "si"/"SI" -> "Si") so the field isn't case-sensitive to use, then checked - * against PERIODIC_TABLE so a typo silently reverts instead of writing a bogus element. - */ - handleElementCommit(): void; - /** - * Selection changes are a visual-only, wave-internal concern (highlight + gizmo, already - * handled inside the mixin) - the only reason React needs to know the indices at all is to - * drive the coordinate panel/toolbar. Without the bypass guard, this setState triggers a - * WaveComponent re-render with a freshly cloned structure prop, which componentDidUpdate - * sees as "changed" and reloads/rebuilds the entire scene on every single click or hover-driven - * selection - orphaning an in-progress drag's mesh reference (D3) and making selection - * sluggish on larger structures (R17). - * - * indices is an array of atomicIndex (D-4: multi-select) - empty for none, one entry for a - * single atom, 2+ for a group selection. - */ - handleSelectionChanged(indices: any): void; - /** - * Selects every atom of one element - the status bar's composition chips double as a - * select-all control. Routed through the mixin's reselectAtomsByIndices so it goes through - * the same single source of truth for onSelectionChanged as every other selection path. - */ - handleSelectElement(elementSymbol: any): void; - handleToggleKeyboardSheet(): void; - handleCloseKeyboardSheet(): void; - handleOpenFigureExport(): void; - handleCloseFigureExport(): void; - /** - * Renders and downloads a figure (U-12). - * - * Reported either way through the status bar's live region. A download is one of the few actions - * with no visible effect inside the app at all - the browser may put the file somewhere the user - * never sees - and an over-large request can exhaust the GL context, where "nothing happened" is - * the least useful possible outcome. Deliberately not routed through handleViewerError: the - * viewer is still fine, and blanking it behind an error card would be a worse lie than the - * failure itself. - */ - handleExportFigure(options: any): void; - handleViewerError(error: any): void; - handleDisplayUnitsChange(displayUnits: any): void; - /** - * Points the camera down a lattice vector. Reset View was the only camera command the viewer - * had (F12); axis views are a primary control in VESTA and CrystalMaker. - */ - handleViewAlongAxis(axis: any): void; - handleRetryViewer(): void; - /** - * Delete/Backspace/Ctrl(Cmd)+Z/Ctrl(Cmd)+Shift+Z don't fire the "keypress" event the rest of - * this component's hotkeys rely on (keypress only fires for character-producing keys), so - * they're handled separately here on "keydown". Guarded the same way as handleKeyPress: - * only while interactive and not while a form field has focus. - */ - handleEditModeKeyDown(event: any): void; - /** - * Public, ref-accessible undo-availability check (part of the host embedding API - a host - * can drive undo/redo via a ref to this component instead of only through this toolbar). - */ - canUndo(): boolean; - canRedo(): boolean; - /** - * Spec §6.3's documented ref API names these undo()/redo() (canUndo/canRedo already matched); - * thin aliases so `ref.current.undo()` works as documented instead of only the internal - * handleUndo/handleRedo names. - */ - undo(): void; - redo(): void; - /** - * The edit surface: an icon strip of tools plus a selection inspector, side by side. - * - * The container is bounded top and bottom (`bottom` clears the status bar) and the inspector - * scrolls inside it. That is the structural half of the F1 fix - the previous single 84 px - * column was ~600 px tall with no scroll, so on a short viewer the coordinate fields were cut - * off and unreachable rather than merely cramped. - */ - renderEditSurface(): import("react/jsx-runtime").JSX.Element; - handleChemicalConnectivityFactorChange(e: any): void; - handleToggleMeasurement(measurementMode: any): void; - handleSetState(newState: any): void; - handleSetMeasurementSettingsForTypeInState(newMeasurementSettingsForType: any): void; - handleDeleteConnection(): void; - handleResetMeasurements(): void; - addHotKeyListener(): void; - removeHotKeyListener(): void; - handleStartGifRecording(downloadPath: any, rotationSpeed?: number, frameDuration?: number): Promise; - componentDidMount(): void; - /** - * Apply toggle-based view settings from URL params after the Wave instance is mounted. - * These settings are imperative (they toggle state on the Wave class instance), - * so they must be applied after componentDidMount when WaveComponent.wave exists. - */ - _applyInitialToggleSettings(): void; - _initialToggleSettingsRetried: boolean | undefined; - _initialToggleSettingsTimeout: number | null | undefined; - componentWillUnmount(): void; - _editHintTimeout: any; - UNSAFE_componentWillReceiveProps(nextProps: any, nextContext: any): void; - _resetStateWaveComponent(): void; - handleSetSetting: (setting: any) => void; - getKeyConfig(): { - [x: string]: () => void; - }; - handleKeyPress: (e: any) => void; - getPrimitiveOrConventionalMaterial(material: any, isConventionalCellShown?: boolean): any; - /** - * Pushes a material to the viewer via the official setStructure()/rebuildScene() path and - * notifies the parent. Used as the setState callback for every history-affecting change - * (edit, undo, redo) once bypassReloadViewer has already been set so WaveComponent's own - * prop-driven reload doesn't race with it. `source` is spec Sec6.2's onEditCommit contract - * (`drag`/`gizmo`/`coordinate-input`/`element-input`/`add`/`remove`/`clone`/`undo`/`redo`) - - * forwarded alongside the back-compat `onUpdate` channel so a host can record history without - * double-counting instead of having to re-infer what kind of edit just happened. - */ - /** - * Shows a transient hint describing the edit that just committed, then clears it. The handle is - * retained so unmount can cancel it - an uncleared setTimeout calling setState on an unmounted - * component is the same defect class as S-2. - */ - _showEditHint(source: any): void; - /** Shared timer behind every transient status-bar hint, edit or otherwise. */ - _showHint(text: any): void; - _applyMaterialToViewer(material: any, source: any): void; - _getWaveProperty(name: any): any; - /** - * Which overlay state the canvas is in, or null for "showing a structure". Error wins over - * empty: if the build threw, the atom count is not evidence of anything. - */ - getViewerStatusKind(): "error" | "empty" | null; - /** - * Canvas size in pixels, for the export dialog's "On-screen" preset and to keep every other - * preset at the canvas aspect ratio. Zeroes are a valid answer (a not-yet-measured container); - * getFigureResolution falls back to 4:3 rather than dividing by zero. - */ - getViewportSize(): { - width: any; - height: any; - }; - /** GL-reported render limit, or undefined so the dialog uses its own conservative default. */ - getMaxFigureDimension(): any; - /** - * The selected atom's coordinates expressed in `displayUnits`, or null when that is already the - * material's own unit (in which case the stored values are shown as-is). - * - * Converts the single touched point through `basis.cell`, the same primitive the delta-based - * edit path uses - never `Basis.toCartesian()/toCrystal()`, which rewrites every atom. - */ - getDisplayCoordinates(): any; - /** - * The armed measurement mode, or null. Read straight off the state the managers already push - * through updateState on every click, so the mode pill and the status-bar readout follow the - * measurement without any new callback out of the mixin. - */ - getActiveMeasurement(): import("../mixins/measurements/MeasurementSettingsHandler").MeasurementSettingsForType | null; - /** - * Element symbol of the single selected atom, or "" for none/multiple. The status bar and the - * edit panel both need it, and the basis stores an element as either a bare symbol or a - * `{ value }` cell depending on the fixture - hence the shared normalizer. - */ - getSelectedElementSymbol(): string; - /** - * Returns a cover div to cover the area and prevent user interaction with component - */ - renderCoverDiv(): import("react/jsx-runtime").JSX.Element; - renderWaveComponent(): import("react/jsx-runtime").JSX.Element; - WaveComponent: WaveComponent | null | undefined; - /** - * On/off state for a menu toggle, plus its hotkey in a fixed slot. Replaces the previous - * grey-checkmark-means-off rendering, which used one shape for both answers (F2), and takes - * the key out of the label text so every row advertises it the same way (F3). - */ - getToggleIndicator(isActive: any, hotKey: any): import("react/jsx-runtime").JSX.Element; - getViewSettingsActions: () => ({ - id: string; - disabled: boolean; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - shouldMenuStayOpened: boolean; - } | { - id: string; - disabled: boolean; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - rightIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - shouldMenuStayOpened: boolean; - isDivider?: undefined; - } | { - id: string; - isDivider: boolean; - disabled?: undefined; - content?: undefined; - leftIcon?: undefined; - rightIcon?: undefined; - onClick?: undefined; - shouldMenuStayOpened?: undefined; - } | { - id: string; - disabled: boolean; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - rightIcon?: undefined; - shouldMenuStayOpened?: undefined; - isDivider?: undefined; - })[]; - getMeasurementsActions: () => ({ - id: string; - content: string; - rightIcon: import("react/jsx-runtime").JSX.Element; - leftIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - shouldMenuStayOpened: boolean; - isDivider?: undefined; - } | { - id: string; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - shouldMenuStayOpened: boolean; - rightIcon?: undefined; - isDivider?: undefined; - } | { - id: string; - isDivider: boolean; - content?: undefined; - rightIcon?: undefined; - leftIcon?: undefined; - onClick?: undefined; - shouldMenuStayOpened?: undefined; - })[]; - getExportActions: () => ({ - id: string; - title: string; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - actions?: undefined; - paperPlacement?: undefined; - } | { - id: string; - title: string; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - actions: { - id: string; - title: string; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - }[]; - paperPlacement: string; - onClick?: undefined; - })[]; - getParametersActions: () => import("react/jsx-runtime").JSX.Element; - /** - * The View items people flip repeatedly rather than set once, promoted out of a dropdown that - * closes on every choice (U-7). Same state and same handlers as the menu entries - this is a - * shortcut, not a move, so the menu keeps working exactly as before. - */ - getQuickToggleItems(): ({ - id: string; - title: string; - hotKey: string; - isActive: boolean; - icon: import("react/jsx-runtime").JSX.Element; - onToggle: () => void; - } | { - id: string; - title: string; - isActive: boolean; - icon: import("react/jsx-runtime").JSX.Element; - onToggle: () => void; - hotKey?: undefined; - })[]; - getToolbarConfig(): ({ - id: string; - title: string; - header: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - actions: ({ - id: string; - disabled: boolean; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - shouldMenuStayOpened: boolean; - } | { - id: string; - disabled: boolean; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - rightIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - shouldMenuStayOpened: boolean; - isDivider?: undefined; - } | { - id: string; - isDivider: boolean; - disabled?: undefined; - content?: undefined; - leftIcon?: undefined; - rightIcon?: undefined; - onClick?: undefined; - shouldMenuStayOpened?: undefined; - } | { - id: string; - disabled: boolean; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - rightIcon?: undefined; - shouldMenuStayOpened?: undefined; - isDivider?: undefined; - })[]; - onClick: () => void; - contentObject?: undefined; - } | { - id: string; - title: string; - header: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - contentObject: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - actions?: undefined; - } | { - id: string; - title: string; - header: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - actions: ({ - id: string; - content: string; - rightIcon: import("react/jsx-runtime").JSX.Element; - leftIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - shouldMenuStayOpened: boolean; - isDivider?: undefined; - } | { - id: string; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - shouldMenuStayOpened: boolean; - rightIcon?: undefined; - isDivider?: undefined; - } | { - id: string; - isDivider: boolean; - content?: undefined; - rightIcon?: undefined; - leftIcon?: undefined; - onClick?: undefined; - shouldMenuStayOpened?: undefined; - })[]; - onClick: () => void; - contentObject?: undefined; - } | { - id: string; - title: string; - header: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - actions: ({ - id: string; - title: string; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - actions?: undefined; - paperPlacement?: undefined; - } | { - id: string; - title: string; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - actions: { - id: string; - title: string; - content: string; - leftIcon: import("react/jsx-runtime").JSX.Element; - onClick: () => void; - }[]; - paperPlacement: string; - onClick?: undefined; - })[]; - onClick: () => void; - contentObject?: undefined; - })[]; - renderViewerWithToolbars(): import("react/jsx-runtime").JSX.Element; - render(): import("react/jsx-runtime").JSX.Element; -} -export namespace ThreeDEditor { - namespace propTypes { - let material: PropTypes.Validator>; - let editable: PropTypes.Requireable; - let isConventionalCellShown: PropTypes.Requireable; - let boundaryConditions: PropTypes.Requireable; - let onUpdate: PropTypes.Requireable<(...args: any[]) => any>; - let onEditCommit: PropTypes.Requireable<(...args: any[]) => any>; - let onEditModeChanged: PropTypes.Requireable<(...args: any[]) => any>; - let onSelectionChanged: PropTypes.Requireable<(...args: any[]) => any>; - let isStandalone: PropTypes.Requireable; - let initialViewSettings: PropTypes.Requireable; - let editSessionOptions: PropTypes.Requireable; - } - namespace defaultProps { - let boundaryConditions_1: {}; - export { boundaryConditions_1 as boundaryConditions }; - let isConventionalCellShown_1: boolean; - export { isConventionalCellShown_1 as isConventionalCellShown }; - let onUpdate_1: undefined; - export { onUpdate_1 as onUpdate }; - let onEditCommit_1: undefined; - export { onEditCommit_1 as onEditCommit }; - let onEditModeChanged_1: undefined; - export { onEditModeChanged_1 as onEditModeChanged }; - let onSelectionChanged_1: undefined; - export { onSelectionChanged_1 as onSelectionChanged }; - let editable_1: boolean; - export { editable_1 as editable }; - let isStandalone_1: boolean; - export { isStandalone_1 as isStandalone }; - let initialViewSettings_1: {}; - export { initialViewSettings_1 as initialViewSettings }; - let editSessionOptions_1: {}; - export { editSessionOptions_1 as editSessionOptions }; - } -} -import React from "react"; -import { WaveComponent } from "./WaveComponent"; -import PropTypes from "prop-types"; diff --git a/dist/components/ThreeDEditor.js b/dist/components/ThreeDEditor.js deleted file mode 100644 index e9ae481f..00000000 --- a/dist/components/ThreeDEditor.js +++ /dev/null @@ -1,1528 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -/* eslint-disable react/sort-comp */ -// import "../MuiClassNameSetup"; -import { DarkMaterialUITheme } from "@mat3ra/cove/dist/theme"; -import ThemeProvider, { AlertProvider } from "@mat3ra/cove/dist/theme/provider"; -import { exportToDisk } from "@mat3ra/cove/dist/utils/downloader"; -import { Made } from "@mat3ra/made"; -import { PERIODIC_TABLE } from "@mat3ra/periodic-table"; -import Article from "@mui/icons-material/Article"; -import Autorenew from "@mui/icons-material/Autorenew"; -import CameraAlt from "@mui/icons-material/CameraAlt"; -import CloudDownload from "@mui/icons-material/CloudDownload"; -import ControlCameraRounded from "@mui/icons-material/ControlCameraRounded"; -import Dehaze from "@mui/icons-material/Dehaze"; -import DeleteIcon from "@mui/icons-material/Delete"; -import Edit from "@mui/icons-material/Edit"; -import FormatShapes from "@mui/icons-material/FormatShapes"; -import GpsFixed from "@mui/icons-material/GpsFixed"; -import HeightIcon from "@mui/icons-material/Height"; -import HelpOutline from "@mui/icons-material/HelpOutline"; -import Image from "@mui/icons-material/Image"; -import ImportExport from "@mui/icons-material/ImportExport"; -import LooksIcon from "@mui/icons-material/Looks"; -import PictureInPicture from "@mui/icons-material/PictureInPicture"; -import Redo from "@mui/icons-material/Redo"; -import RemoveRedEye from "@mui/icons-material/RemoveRedEye"; -import Replay from "@mui/icons-material/Replay"; -import Settings from "@mui/icons-material/Settings"; -import Spellcheck from "@mui/icons-material/Spellcheck"; -import SquareFootIcon from "@mui/icons-material/SquareFoot"; -import SwitchCamera from "@mui/icons-material/SwitchCamera"; -import ThreeDRotation from "@mui/icons-material/ThreeDRotation"; -import Undo from "@mui/icons-material/Undo"; -import ScopedCssBaseline from "@mui/material/ScopedCssBaseline"; -import Stack from "@mui/material/Stack"; -import PropTypes from "prop-types"; -import React from "react"; -import { LABEL_TYPES, MEASUREMENT_MODES } from "../enums"; -import { defaultMeasurementsSettings, MeasurementSettingsHandler, } from "../mixins/measurements/MeasurementSettingsHandler"; -import settings from "../settings"; -import { describeEditCommit, EDIT_HINT_TIMEOUT_MS } from "../utils/editActions"; -import { matchesEditorKey } from "../utils/keyBindings"; -import { formatMeasurementValue } from "../utils/measurementReadout"; -import EditToolbar from "./EditToolbar"; -import FigureExportDialog from "./FigureExportDialog"; -import IconsToolbar from "./IconsToolbar"; -import KeyboardSheet from "./KeyboardSheet"; -import ModePill from "./ModePill"; -import ParametersMenu, { clampParameterSettings } from "./ParametersMenu"; -import QuickToggles from "./QuickToggles"; -import SelectionInspector from "./SelectionInspector"; -import StatusBar, { normalizeElement } from "./StatusBar"; -import ToggleIndicator from "./ToggleIndicator"; -import { ViewerErrorBoundary } from "./ViewerErrorBoundary"; -import ViewerStatus from "./ViewerStatus"; -import { WaveComponent } from "./WaveComponent"; -/** - * Maximum number of undo/redo entries retained. Each entry is a full Material clone, so this - * bounds the editor's memory footprint over a long session; older entries fall off the back. - */ -const MAX_HISTORY_ENTRIES = 50; -/** - * Wrapper component containing 3D visualization through `WaveComponent` and the associated controls - */ -export class ThreeDEditor extends React.Component { - /** - * Create a ThreeDEditor component - * @param props Properties as explained below - */ - constructor(props) { - var _a, _b, _c, _d, _e, _f, _g; - super(props); - this.handleSetSetting = (setting) => { - const { viewerSettings } = this.state; - this.setState({ - viewerSettings: { - ...viewerSettings, - ...setting, - }, - }); - }; - this.handleKeyPress = (e) => { - const { isInteractive } = this.state; - const { editable } = this.props; - // Check if interactive mode is off, or if the event originated from an input-like element - if (!isInteractive || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) { - return; - } - // Removing the toggleEditMode key from the keyConfig if the editor is not editable - const keyConfigAdjusted = { ...this.getKeyConfig() }; - if (!editable) { - delete keyConfigAdjusted[settings.hotKeysConfig.toggleEditMode]; - } - const handler = keyConfigAdjusted[e.key.toLowerCase()]; - if (handler) { - handler.call(this); - } - }; - this.getViewSettingsActions = () => { - const { viewerSettings, isConventionalCellShown } = this.state; - const areLabelsVisibleByType = (type) => { var _a, _b; return (_b = (_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave) === null || _b === void 0 ? void 0 : _b.areLabelsVisibleByType(type); }; - return [ - { - id: "rotate-zoom", - disabled: false, - content: "Rotate/Zoom", - leftIcon: _jsx(ThreeDRotation, {}), - rightIcon: this.getToggleIndicator(this._getWaveProperty("areOrbitControlsEnabled"), settings.hotKeysConfig.toggleOrbitControls), - onClick: this.handleToggleOrbitControls, - shouldMenuStayOpened: true, - }, - { - id: "auto-rotate", - disabled: false, - content: "Auto Rotate", - leftIcon: _jsx(Autorenew, {}), - rightIcon: this.getToggleIndicator(this._getWaveProperty("isOrbitControlsAnimationEnabled")), - onClick: this.handleToggleOrbitControlsAnimation, - shouldMenuStayOpened: true, - }, - { - id: "toggle-axes", - disabled: false, - content: "Axes", - leftIcon: _jsx(GpsFixed, {}), - rightIcon: this.getToggleIndicator(this._getWaveProperty("areAxesEnabled")), - onClick: this.handleToggleAxes, - shouldMenuStayOpened: true, - }, - { - id: "toggle-camera", - disabled: false, - content: "Orthographic Camera", - leftIcon: _jsx(SwitchCamera, {}), - rightIcon: this.getToggleIndicator(this._getWaveProperty("isCameraOrthographic")), - onClick: this.handleToggleOrthographicCamera, - shouldMenuStayOpened: true, - }, - { - id: "toggle-bonds", - disabled: false, - content: "Bonds", - leftIcon: _jsx(Dehaze, {}), - rightIcon: this.getToggleIndicator(this._getWaveProperty("isDrawBondsEnabled"), settings.hotKeysConfig.toggleBonds), - onClick: this.handleToggleBonds, - shouldMenuStayOpened: true, - }, - { - id: "toggle-cell", - disabled: false, - content: "Conventional Cell", - leftIcon: _jsx(FormatShapes, {}), - rightIcon: this.getToggleIndicator(isConventionalCellShown), - onClick: this.handleToggleConventionalCell, - shouldMenuStayOpened: true, - }, - { - id: "toggle-element-labels", - disabled: false, - content: "Elements", - leftIcon: _jsx(Spellcheck, {}), - rightIcon: this.getToggleIndicator(areLabelsVisibleByType && areLabelsVisibleByType("element"), settings.hotKeysConfig.toggleElementLabels), - onClick: this.handleToggleElementLabels, - shouldMenuStayOpened: true, - }, - { - id: "toggle-coordinate-labels", - disabled: false, - content: "Coordinates", - leftIcon: _jsx(Spellcheck, {}), - rightIcon: this.getToggleIndicator(areLabelsVisibleByType && areLabelsVisibleByType("coordinate"), settings.hotKeysConfig.toggleCoordinateLabels), - onClick: this.handleToggleCoordinateLabels, - shouldMenuStayOpened: true, - }, - { - id: "toggle-view-adjustment", - disabled: false, - content: "Auto-center on change", - leftIcon: _jsx(ControlCameraRounded, {}), - rightIcon: this.getToggleIndicator(viewerSettings.isViewAdjustable), - onClick: this.handleToggleIsViewAdjustable, - shouldMenuStayOpened: true, - }, - { - id: "divider-camera", - isDivider: true, - }, - ...[ - { axis: "a", label: "View along a" }, - { axis: "b", label: "View along b" }, - { axis: "c", label: "View along c" }, - { axis: "111", label: "View along [111]" }, - ].map(({ axis, label }) => ({ - id: `view-along-${axis}`, - disabled: false, - content: label, - leftIcon: _jsx(CameraAlt, {}), - onClick: () => this.handleViewAlongAxis(axis), - shouldMenuStayOpened: true, - })), - { - id: "divider-2", - isDivider: true, - }, - { - id: "reset-view", - disabled: false, - content: "Reset View", - leftIcon: _jsx(Replay, {}), - onClick: this.handleResetViewer, - shouldMenuStayOpened: true, - }, - { - id: "divider-help", - isDivider: true, - }, - { - // The sheet was reachable only by pressing `?` (U-3), which is unreachable on a - // device with no keyboard - so the one surface documenting how to drive the viewer - // was missing exactly where a user most needs it (U-13). The key still works. - id: "keyboard-sheet", - disabled: false, - content: "Shortcuts & gestures", - leftIcon: _jsx(HelpOutline, {}), - onClick: this.handleToggleKeyboardSheet, - }, - ]; - }; - this.getMeasurementsActions = () => { - const { measurementsSettings } = this.state; - const measurementsSettingsHandler = new MeasurementSettingsHandler(measurementsSettings); - return [ - { - id: "Distances", - content: "Distances", - rightIcon: this.getToggleIndicator(measurementsSettingsHandler.isMeasurementActiveByType(MEASUREMENT_MODES.DISTANCE), settings.hotKeysConfig.toggleDistanceShown), - leftIcon: _jsx(HeightIcon, {}), - onClick: () => this.handleToggleMeasurement(MEASUREMENT_MODES.DISTANCE), - shouldMenuStayOpened: true, - }, - { - id: "Angles", - content: "Angles", - rightIcon: this.getToggleIndicator(measurementsSettingsHandler.isMeasurementActiveByType(MEASUREMENT_MODES.ANGLE), settings.hotKeysConfig.toggleAnglesShown), - leftIcon: _jsx(LooksIcon, {}), - onClick: () => this.handleToggleMeasurement(MEASUREMENT_MODES.ANGLE), - shouldMenuStayOpened: true, - }, - { - id: "Coordinates", - content: "Copy Coordinates", - rightIcon: this.getToggleIndicator(measurementsSettingsHandler.isMeasurementActiveByType(MEASUREMENT_MODES.COORDINATE), settings.hotKeysConfig.toggleCopyCoordinatesShown), - leftIcon: _jsx(GpsFixed, {}), - onClick: () => this.handleToggleMeasurement(MEASUREMENT_MODES.COORDINATE), - shouldMenuStayOpened: true, - }, - { - id: "Delete", - content: "Delete connection", - leftIcon: _jsx(DeleteIcon, {}), - onClick: this.handleDeleteConnection, - shouldMenuStayOpened: true, - }, - { - id: "divider-actions", - isDivider: true, - }, - { - id: "Reset measurements", - content: "Reset measurements", - leftIcon: _jsx(Replay, {}), - onClick: this.handleResetMeasurements, - shouldMenuStayOpened: true, - }, - ]; - }; - this.getExportActions = () => { - const downloadActions = [ - { - id: "JSON", - title: "JSON", - content: "JSON", - leftIcon: _jsx(Article, {}), - onClick: () => this.handleDownloadClick("json"), - }, - { - id: "POSCAR", - title: "POSCAR", - content: "POSCAR", - leftIcon: _jsx(Article, {}), - onClick: () => this.handleDownloadClick("poscar"), - }, - ]; - return [ - { - id: "StartGif", - title: "Auto Rotate GIF", - content: "Auto Rotate GIF", - leftIcon: _jsx(PictureInPicture, {}), - onClick: () => this.handleStartGifRecording(), - }, - { - id: "Screenshot", - title: "Screenshot", - content: "Screenshot", - leftIcon: _jsx(PictureInPicture, {}), - onClick: this.handleTakeScreenshot, - }, - { - // Kept alongside Screenshot rather than replacing it: one-click capture of exactly - // what is on screen is still the common case, and a dialog in front of it would be - // a tax on it. This is the publication case (U-12) - chosen resolution, chosen - // background, scale bar - which the canvas readback structurally cannot do. - id: "Figure", - title: "Export figure", - content: "Figure (PNG)…", - leftIcon: _jsx(Image, {}), - onClick: this.handleOpenFigureExport, - }, - { - id: "Download", - title: "Download", - content: "Download", - leftIcon: _jsx(CloudDownload, {}), - actions: downloadActions, - paperPlacement: "right-start", - }, - ]; - }; - this.getParametersActions = () => { - var _a, _b; - const { viewerSettings, material } = this.state; - return (_jsx(ParametersMenu, { viewerSettings: viewerSettings, - // Lets the menu state what a repetition will cost instead of leaving the user to - // discover it by waiting (F7). - atomCountInCell: ((_b = (_a = material === null || material === void 0 ? void 0 : material.basis) === null || _a === void 0 ? void 0 : _a.elements) === null || _b === void 0 ? void 0 : _b.length) || 0, - // One value-based callback in place of three event-shaped ones: sliders report a - // value, not an event, and handleSetSetting already takes a settings patch. - onSettingChange: this.handleSetSetting })); - }; - const { boundaryConditions, isConventionalCellShown, material, initialViewSettings = {}, } = this.props; - // TODO : overloading a bunch of props and state attributes here.. - this.state = { - // on/off switch for the component - isInteractive: false, - activeToolbarMenu: null, - isEditModeActive: false, - activeTransformMode: "translate", - historyStack: [material], - historyPointer: 0, - // Array of atomicIndex, per the mixin's multi-select onSelectionChanged contract - // (D-4); empty = nothing selected, one entry = the common single-atom case. - selectedAtomIndices: [], - // Local draft strings for the X/Y/Z coordinate fields, indexed by axis; null means - // "show the committed value". Lets a field be cleared or start with "-" while - // focused without committing (and rebuilding the scene) on every keystroke (D18). - coordinateDrafts: [null, null, null], - // Local draft string for the element field while focused; null means "show the - // committed value". Mirrors coordinateDrafts (D18) - no commit/history entry until - // blur/Enter. - elementDraft: null, - // isDistanceAndAnglesShown: false, - measurementsSettings: defaultMeasurementsSettings, - // Keyboard sheet (`?`) visibility - it is help, so available whenever interactive. - isKeyboardSheetOpen: false, - // Figure export dialog (U-12) visibility. - isFigureExportOpen: false, - // Units the inspector *shows*. Independent of material.basis.units, which is what - // the material stores and the only thing edits write to. - displayUnits: null, - // Transient "what just happened" hint, from onEditCommit's own {source} enum. - lastActionHint: null, - // Set when the viewer subtree throws; cleared by a retry, which also bumps - // viewerResetKey to remount the boundary's children. - viewerError: null, - viewerResetKey: 0, - // TODO: remove the need for `viewerTriggerResize` - // whether to trigger resize - viewerTriggerResize: false, - // Settings of the wave viewer, merged with any initial overrides from URL params. - // Clamped on the way in (clampParameterSettings): these values also arrive from a saved - // URL or a host's initialViewSettings, neither of which the parameters menu can vet, and - // an out-of-range one renders at that value while its slider pins at the maximum - then - // silently snaps there on first touch. - viewerSettings: clampParameterSettings({ - isViewAdjustable: (_a = initialViewSettings.isViewAdjustable) !== null && _a !== void 0 ? _a : settings.isViewAdjustable, - atomRadiiScale: (_b = initialViewSettings.atomRadiiScale) !== null && _b !== void 0 ? _b : settings.atomRadiiScale, - repetitionsAlongLatticeVectorA: (_c = initialViewSettings.repetitionsAlongLatticeVectorA) !== null && _c !== void 0 ? _c : settings.repetitions, - repetitionsAlongLatticeVectorB: (_d = initialViewSettings.repetitionsAlongLatticeVectorB) !== null && _d !== void 0 ? _d : settings.repetitions, - repetitionsAlongLatticeVectorC: (_e = initialViewSettings.repetitionsAlongLatticeVectorC) !== null && _e !== void 0 ? _e : settings.repetitions, - chemicalConnectivityFactor: (_f = initialViewSettings.chemicalConnectivityFactor) !== null && _f !== void 0 ? _f : settings.chemicalConnectivityFactor, - }), - // Toggle settings from URL to apply after Wave instance mounts - _initialToggleSettings: { - orthographicCamera: initialViewSettings.orthographicCamera, - bonds: initialViewSettings.bonds, - axes: initialViewSettings.axes, - autoRotate: initialViewSettings.autoRotate, - elementLabels: initialViewSettings.elementLabels, - coordinateLabels: initialViewSettings.coordinateLabels, - }, - boundaryConditions, - isConventionalCellShown: (_g = initialViewSettings.conventionalCell) !== null && _g !== void 0 ? _g : isConventionalCellShown, - // material as originally passed in by the host, before any in-editor modification. - originalMaterial: material, - // material that is passed to WaveComponent to be visualized and may have repetition and radius adjusted. - material: props.material.clone(), - }; - this.handleCellRepetitionsChange = this.handleCellRepetitionsChange.bind(this); - this.handleSphereRadiusChange = this.handleSphereRadiusChange.bind(this); - this.handleDownloadClick = this.handleDownloadClick.bind(this); - this.handleToggleInteractive = this.handleToggleInteractive.bind(this); - this.handleToggleToolbarMenu = this.handleToggleToolbarMenu.bind(this); - this.handleToggleBonds = this.handleToggleBonds.bind(this); - this.handleToggleEditMode = this.handleToggleEditMode.bind(this); - this.handleToggleOrthographicCamera = this.handleToggleOrthographicCamera.bind(this); - this.handleToggleElementLabels = this.handleToggleElementLabels.bind(this); - this.handleToggleCoordinateLabels = this.handleToggleCoordinateLabels.bind(this); - this.handleToggleConventionalCell = this.handleToggleConventionalCell.bind(this); - this.handleToggleIsViewAdjustable = this.handleToggleIsViewAdjustable.bind(this); - this.handleResetViewer = this.handleResetViewer.bind(this); - this.handleTakeScreenshot = this.handleTakeScreenshot.bind(this); - this.handleToggleOrbitControls = this.handleToggleOrbitControls.bind(this); - this.handleToggleOrbitControlsAnimation = - this.handleToggleOrbitControlsAnimation.bind(this); - this.handleToggleAxes = this.handleToggleAxes.bind(this); - this.handleStructureModified = this.handleStructureModified.bind(this); - this.handleUndo = this.handleUndo.bind(this); - this.handleRedo = this.handleRedo.bind(this); - this.handleCoordinateChange = this.handleCoordinateChange.bind(this); - this.handleCoordinateDraftChange = this.handleCoordinateDraftChange.bind(this); - this.handleCoordinateCommit = this.handleCoordinateCommit.bind(this); - this.handleSetTransformMode = this.handleSetTransformMode.bind(this); - this.handleAddAtom = this.handleAddAtom.bind(this); - this.handleRemoveSelectedAtom = this.handleRemoveSelectedAtom.bind(this); - this.handleCloneSelectedAtoms = this.handleCloneSelectedAtoms.bind(this); - this.handleFocusCameraOnSelection = this.handleFocusCameraOnSelection.bind(this); - this.handleElementDraftChange = this.handleElementDraftChange.bind(this); - this.handleElementCommit = this.handleElementCommit.bind(this); - this.handleSelectionChanged = this.handleSelectionChanged.bind(this); - this.handleSelectElement = this.handleSelectElement.bind(this); - this.handleToggleKeyboardSheet = this.handleToggleKeyboardSheet.bind(this); - this.handleCloseKeyboardSheet = this.handleCloseKeyboardSheet.bind(this); - this.handleOpenFigureExport = this.handleOpenFigureExport.bind(this); - this.handleCloseFigureExport = this.handleCloseFigureExport.bind(this); - this.handleExportFigure = this.handleExportFigure.bind(this); - this.handleViewerError = this.handleViewerError.bind(this); - this.handleDisplayUnitsChange = this.handleDisplayUnitsChange.bind(this); - this.handleViewAlongAxis = this.handleViewAlongAxis.bind(this); - this.handleRetryViewer = this.handleRetryViewer.bind(this); - this.handleEditModeKeyDown = this.handleEditModeKeyDown.bind(this); - this.canUndo = this.canUndo.bind(this); - this.canRedo = this.canRedo.bind(this); - this.undo = this.undo.bind(this); - this.redo = this.redo.bind(this); - this.renderEditSurface = this.renderEditSurface.bind(this); - this.handleChemicalConnectivityFactorChange = - this.handleChemicalConnectivityFactorChange.bind(this); - this.handleToggleMeasurement = this.handleToggleMeasurement.bind(this); - this.handleSetState = this.handleSetState.bind(this); - this.handleSetMeasurementSettingsForTypeInState = - this.handleSetMeasurementSettingsForTypeInState.bind(this); - this.handleDeleteConnection = this.handleDeleteConnection.bind(this); - this.handleResetMeasurements = this.handleResetMeasurements.bind(this); - this.addHotKeyListener = this.addHotKeyListener.bind(this); - this.removeHotKeyListener = this.removeHotKeyListener.bind(this); - this.handleStartGifRecording = this.handleStartGifRecording.bind(this); - } - componentDidMount() { - this.addHotKeyListener(); - document.addEventListener("keydown", this.handleEditModeKeyDown); - this._applyInitialToggleSettings(); - } - /** - * Apply toggle-based view settings from URL params after the Wave instance is mounted. - * These settings are imperative (they toggle state on the Wave class instance), - * so they must be applied after componentDidMount when WaveComponent.wave exists. - */ - _applyInitialToggleSettings() { - var _a; - const { _initialToggleSettings } = this.state; - if (!_initialToggleSettings) - return; - // These settings arrive from URL params and are applied imperatively to the Wave - // instance, so they need one to exist. Returning early used to drop them permanently - // whenever the instance was not ready yet, silently losing every toggle in a shared - // view link; retry on the next tick instead, once the child's own componentDidMount - // has constructed it. - if (!((_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave)) { - if (this._initialToggleSettingsRetried) - return; - this._initialToggleSettingsRetried = true; - this._initialToggleSettingsTimeout = setTimeout(() => this._applyInitialToggleSettings(), 0); - return; - } - if (_initialToggleSettings.orthographicCamera) { - this.handleToggleOrthographicCamera(); - } - if (_initialToggleSettings.bonds) { - this.handleToggleBonds(); - } - if (_initialToggleSettings.axes) { - this.handleToggleAxes(); - } - if (_initialToggleSettings.autoRotate) { - this.handleToggleOrbitControlsAnimation(); - } - if (_initialToggleSettings.elementLabels) { - this.handleToggleElementLabels(); - } - if (_initialToggleSettings.coordinateLabels) { - this.handleToggleCoordinateLabels(); - } - } - componentWillUnmount() { - this.removeHotKeyListener(); - document.removeEventListener("keydown", this.handleEditModeKeyDown); - if (this._initialToggleSettingsTimeout) { - clearTimeout(this._initialToggleSettingsTimeout); - this._initialToggleSettingsTimeout = null; - } - if (this._editHintTimeout) { - clearTimeout(this._editHintTimeout); - this._editHintTimeout = null; - } - } - /** - * Delete/Backspace/Ctrl(Cmd)+Z/Ctrl(Cmd)+Shift+Z don't fire the "keypress" event the rest of - * this component's hotkeys rely on (keypress only fires for character-producing keys), so - * they're handled separately here on "keydown". Guarded the same way as handleKeyPress: - * only while interactive and not while a form field has focus. - */ - handleEditModeKeyDown(event) { - const { isInteractive, isEditModeActive } = this.state; - if (!isInteractive || ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.nodeName)) { - return; - } - // Matched against settings.editorKeysConfig rather than inline comparisons, so these keys - // reach the keyboard sheet (U-3) from the same declaration the handler uses. Escape is - // declared there too but implemented in the mixin, which owns cancel-drag-versus-deselect. - const { undo, redo, removeSelected } = settings.editorKeysConfig; - // Undo/redo are deliberately NOT gated on edit mode (F6): leaving edit mode does not clear - // historyStack, so gating them made a surviving history unreachable. They are gated on - // there being something to do instead - which also keeps the viewer from swallowing a key - // an embedding host may want for its own undo when our stack is empty. - // - // Redo is checked first: it is undo's binding plus Shift, and matchesEditorKey treats - // Shift as significant, so order only matters if a future binding relaxes that. - if (matchesEditorKey(event, redo)) { - if (!this.canRedo()) - return; - event.preventDefault(); - this.handleRedo(); - } - else if (matchesEditorKey(event, undo)) { - if (!this.canUndo()) - return; - event.preventDefault(); - this.handleUndo(); - } - else if (isEditModeActive && matchesEditorKey(event, removeSelected)) { - // Removing an atom is an edit-mode action; outside it there is no selection to remove. - this.handleRemoveSelectedAtom(); - } - } - // TODO: update component to fully controlled or fully uncontrolled with a key? - // https://reactjs.org/docs/react-component.html#unsafe_componentwillreceiveprops - // eslint-disable-next-line no-unused-vars - UNSAFE_componentWillReceiveProps(nextProps, nextContext) { - const { material } = nextProps; - if (!material) - return; - const { material: currentMaterial } = this.state; - // A host that stores every onUpdate(material) and passes it straight back down (the - // common "lift state up" pattern) must not have its own echo wipe our undo history and - // selection - only reset when the incoming material's actual content differs from what - // we're already showing (D16). calculateHash() is a real content hash (not a stored, - // possibly-absent prop), so this can't false-positive on two materials that both happen - // to lack a cached hash. - let isSameContent = false; - try { - isSameContent = - !!currentMaterial && material.calculateHash() === currentMaterial.calculateHash(); - } - catch (error) { - isSameContent = false; - } - if (isSameContent) - return; - const clonedMaterial = material.clone(); - this.setState({ - material: clonedMaterial, - originalMaterial: material, - boundaryConditions: nextProps.boundaryConditions || {}, - isConventionalCellShown: nextProps.isConventionalCellShown || false, - // Undo/redo history is scoped to the material currently being edited. - historyStack: [clonedMaterial], - historyPointer: 0, - selectedAtomIndices: [], - coordinateDrafts: [null, null, null], - elementDraft: null, - }); - this.handleResetMeasurements(); - } - _resetStateWaveComponent() { - // eslint-disable-next-line react/no-unused-state - this.setState({ wave: this.WaveComponent.wave }); - } - // map of hotkeys to their handlers - getKeyConfig() { - return { - [settings.hotKeysConfig.toggleKeyboardSheet]: this.handleToggleKeyboardSheet, - [settings.hotKeysConfig.toggleOrbitControls]: this.handleToggleOrbitControls, - [settings.hotKeysConfig.toggleInteractive]: this.handleToggleInteractive, - [settings.hotKeysConfig.toggleBonds]: this.handleToggleBonds, - [settings.hotKeysConfig.toggleElementLabels]: this.handleToggleElementLabels, - [settings.hotKeysConfig.toggleCoordinateLabels]: this.handleToggleCoordinateLabels, - [settings.hotKeysConfig.resetViewer]: this.handleResetViewer, - [settings.hotKeysConfig.toggleEditMode]: this.handleToggleEditMode, - [settings.hotKeysConfig.toggleDistanceShown]: this.handleToggleMeasurement.bind(this, MEASUREMENT_MODES.DISTANCE), - [settings.hotKeysConfig.toggleAnglesShown]: this.handleToggleMeasurement.bind(this, MEASUREMENT_MODES.ANGLE), - [settings.hotKeysConfig.toggleCopyCoordinatesShown]: this.handleToggleMeasurement.bind(this, MEASUREMENT_MODES.COORDINATE), - [settings.hotKeysConfig.deleteConnection]: this.handleDeleteConnection, - }; - } - addHotKeyListener() { - document.addEventListener("keypress", this.handleKeyPress, true); - } - removeHotKeyListener() { - document.removeEventListener("keypress", this.handleKeyPress, true); - } - handleCellRepetitionsChange(e) { - this.handleSetSetting({ [e.target.id]: parseFloat(e.target.value) }); - } - handleSphereRadiusChange(e) { - this.handleSetSetting({ atomRadiiScale: parseFloat(e.target.value) }); - } - handleToggleOrthographicCamera() { - this.WaveComponent.wave.toggleOrthographicCamera(); - this._resetStateWaveComponent(); - } - handleToggleElementLabels() { - this.WaveComponent.wave.toggleLabelsVisibilityByType(LABEL_TYPES.ELEMENT); - this._resetStateWaveComponent(); - } - handleToggleCoordinateLabels() { - this.WaveComponent.wave.toggleLabelsVisibilityByType(LABEL_TYPES.COORDINATE); - this._resetStateWaveComponent(); - } - handleChemicalConnectivityFactorChange(e) { - this.handleSetSetting({ chemicalConnectivityFactor: parseFloat(e.target.value) }); - } - // eslint-disable-next-line class-methods-use-this - getPrimitiveOrConventionalMaterial(material, isConventionalCellShown = false) { - return isConventionalCellShown ? material.getACopyWithConventionalCell() : material.clone(); - } - handleToggleConventionalCell() { - const { isConventionalCellShown, originalMaterial } = this.state; - this.handleResetMeasurements(); - this.setState({ - isConventionalCellShown: !isConventionalCellShown, - originalMaterial: this.getPrimitiveOrConventionalMaterial(originalMaterial, !isConventionalCellShown), - }); - } - handleToggleIsViewAdjustable() { - const { viewerSettings: { isViewAdjustable }, } = this.state; - this.handleSetSetting({ isViewAdjustable: !isViewAdjustable }); - } - handleDownloadClick(format = "poscar") { - // Exports the current working material, not originalMaterial (the pre-edit snapshot) - - // otherwise a download taken after any edit silently returns the old structure (D8). - const { material } = this.state; - let content; - switch (format) { - case "poscar": - content = material.getAsPOSCAR(); - break; - default: - content = JSON.stringify(material.toJSON()); - } - exportToDisk(content, material.name, format); - } - handleToggleInteractive() { - const { isInteractive } = this.state; - this.setState({ isInteractive: !isInteractive }); - } - handleToggleToolbarMenu(toolbarMenuName) { - this.setState((prevState) => ({ - activeToolbarMenu: prevState.activeToolbarMenu === toolbarMenuName ? null : toolbarMenuName, - })); - } - handleToggleBonds() { - const { wave } = this.WaveComponent; - wave.isDrawBondsEnabled = !wave.isDrawBondsEnabled; // toggle value; - this._resetStateWaveComponent(); - } - handleToggleEditMode() { - const { isEditModeActive: wasEditModeActive } = this.state; - const { onEditModeChanged } = this.props; - const isEditModeActive = !wasEditModeActive; - // Edit mode and measurement modes interpret the same clicks differently (select-atom vs. - // measure); letting both be active at once double-handles every click (D20). Entering - // edit mode force-disables any active measurement mode. - if (isEditModeActive) { - this.handleResetMeasurements(); - } - this.setState({ isEditModeActive }, () => { - if (this.WaveComponent && this.WaveComponent.wave) { - this.WaveComponent.wave.enableEditMode(isEditModeActive); - } - if (onEditModeChanged) - onEditModeChanged(isEditModeActive); - }); - } - /** - * Pushes a material to the viewer via the official setStructure()/rebuildScene() path and - * notifies the parent. Used as the setState callback for every history-affecting change - * (edit, undo, redo) once bypassReloadViewer has already been set so WaveComponent's own - * prop-driven reload doesn't race with it. `source` is spec Sec6.2's onEditCommit contract - * (`drag`/`gizmo`/`coordinate-input`/`element-input`/`add`/`remove`/`clone`/`undo`/`redo`) - - * forwarded alongside the back-compat `onUpdate` channel so a host can record history without - * double-counting instead of having to re-infer what kind of edit just happened. - */ - /** - * Shows a transient hint describing the edit that just committed, then clears it. The handle is - * retained so unmount can cancel it - an uncleared setTimeout calling setState on an unmounted - * component is the same defect class as S-2. - */ - _showEditHint(source) { - this._showHint(describeEditCommit(source)); - } - /** Shared timer behind every transient status-bar hint, edit or otherwise. */ - _showHint(text) { - if (!text) - return; - if (this._editHintTimeout) - clearTimeout(this._editHintTimeout); - this.setState({ lastActionHint: text }); - this._editHintTimeout = setTimeout(() => { - this._editHintTimeout = null; - this.setState({ lastActionHint: null }); - }, EDIT_HINT_TIMEOUT_MS); - } - _applyMaterialToViewer(material, source) { - const { onUpdate, onEditCommit } = this.props; - this._showEditHint(source); - if (this.WaveComponent && this.WaveComponent.wave) { - this.WaveComponent.wave.bypassReloadViewer = false; - this.WaveComponent.wave.setStructure(material); - this.WaveComponent.wave.rebuildScene(); - } - if (onUpdate) { - onUpdate(material); - } - if (onEditCommit) { - onEditCommit(material, { source }); - } - } - handleStructureModified(newMaterial, source) { - const { material, historyStack, historyPointer } = this.state; - if (this.WaveComponent && this.WaveComponent.wave) { - this.WaveComponent.wave.bypassReloadViewer = true; - } - newMaterial.setLattice({ - ...newMaterial.getLattice().toJSON(), - type: material.getLattice().type, - }); - const clonedMaterial = newMaterial.clone(); - const newStack = historyStack.slice(0, historyPointer + 1); - newStack.push(clonedMaterial); - // Every entry is a full Material clone, so an unbounded stack grows without limit for - // as long as the session lasts - material to worry about on structures with thousands - // of atoms. Drop the oldest entries past the cap; the pointer moves with them. - const overflow = Math.max(0, newStack.length - MAX_HISTORY_ENTRIES); - const cappedStack = overflow ? newStack.slice(overflow) : newStack; - this.setState({ - material: clonedMaterial, - historyStack: cappedStack, - historyPointer: cappedStack.length - 1, - }, () => this._applyMaterialToViewer(clonedMaterial, source)); - } - handleUndo() { - const { historyStack, historyPointer } = this.state; - if (historyPointer <= 0) - return; - const previousPointer = historyPointer - 1; - const previousMaterial = historyStack[previousPointer]; - if (this.WaveComponent && this.WaveComponent.wave) { - this.WaveComponent.wave.bypassReloadViewer = true; - } - this.setState({ - material: previousMaterial, - historyPointer: previousPointer, - }, () => this._applyMaterialToViewer(previousMaterial, "undo")); - } - handleRedo() { - const { historyStack, historyPointer } = this.state; - if (historyPointer >= historyStack.length - 1) - return; - const nextPointer = historyPointer + 1; - const nextMaterial = historyStack[nextPointer]; - if (this.WaveComponent && this.WaveComponent.wave) { - this.WaveComponent.wave.bypassReloadViewer = true; - } - this.setState({ - material: nextMaterial, - historyPointer: nextPointer, - }, () => this._applyMaterialToViewer(nextMaterial, "redo")); - } - /** - * Public, ref-accessible undo-availability check (part of the host embedding API - a host - * can drive undo/redo via a ref to this component instead of only through this toolbar). - */ - canUndo() { - const { historyPointer } = this.state; - return historyPointer > 0; - } - canRedo() { - const { historyStack, historyPointer } = this.state; - return historyPointer < historyStack.length - 1; - } - /** - * Spec §6.3's documented ref API names these undo()/redo() (canUndo/canRedo already matched); - * thin aliases so `ref.current.undo()` works as documented instead of only the internal - * handleUndo/handleRedo names. - */ - undo() { - this.handleUndo(); - } - redo() { - this.handleRedo(); - } - /** - * Commits a single axis of the selected atom's coordinate. Only called once per field edit - * (on blur/Enter, via handleCoordinateCommit below) rather than per keystroke, so this is - * naturally one history entry per edit (D18). Mutates a clone's basis in place via - * Basis/setBasis rather than reconstructing via fromElementsAndCoordinates, so labels and - * constraints on every atom (including the one being edited) survive untouched (D7). Only - * meaningful for exactly one selected atom - the coordinate panel itself is hidden for 0 or - * 2+ selected (see renderEditSurface), so this is a defensive guard, not the primary gate. - */ - handleCoordinateChange(axisIndex, value) { - var _a, _b; - const { selectedAtomIndices, material } = this.state; - if (selectedAtomIndices.length !== 1) - return; - const [selectedAtomIndex] = selectedAtomIndices; - const floatValue = parseFloat(value); - if (Number.isNaN(floatValue)) - return; - const newMaterial = material.clone(); - const basis = newMaterial.getBasis(); - const { coordinates } = basis; - if (!coordinates[selectedAtomIndex]) - return; - const currentValue = coordinates[selectedAtomIndex].value; - const updatedValue = [...currentValue]; - updatedValue[axisIndex] = floatValue; - coordinates[selectedAtomIndex] = { ...coordinates[selectedAtomIndex], value: updatedValue }; - basis.coordinates = coordinates; - newMaterial.setBasis(basis.toJSON()); - // Fast in-place scene update for immediate visual feedback before state propagates. - // Three.js mesh positions are always Cartesian, so this only makes sense when the - // material's own coordinates are too; for crystal-unit materials, skip straight to the - // full rebuild below rather than briefly snapping the mesh to the wrong (fractional) spot. - if (basis.units === "cartesian" && ((_b = (_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave) === null || _b === void 0 ? void 0 : _b.selectedMesh_)) { - this.WaveComponent.wave.selectedMesh_.position.setComponent(axisIndex, floatValue); - this.WaveComponent.wave.render(); - } - this.handleStructureModified(newMaterial, "coordinate-input"); - } - /** - * Updates only the local draft string for one coordinate field while it's focused - no - * commit, no history entry, no scene rebuild. Lets the field be cleared or start with "-" - * without the browser's number-input semantics dropping the keystroke (D18). - */ - handleCoordinateDraftChange(axisIndex, value) { - this.setState((prevState) => { - const coordinateDrafts = [...prevState.coordinateDrafts]; - coordinateDrafts[axisIndex] = value; - return { coordinateDrafts }; - }); - } - /** - * Commits the draft for one coordinate field (on blur/Enter) if it parses to a real number, - * then clears the draft so the field reverts to showing the committed value. - */ - handleCoordinateCommit(axisIndex) { - const { coordinateDrafts } = this.state; - const draftValue = coordinateDrafts[axisIndex]; - if (draftValue !== null && draftValue !== "" && !Number.isNaN(parseFloat(draftValue))) { - this.handleCoordinateChange(axisIndex, draftValue); - } - this.setState((prevState) => { - const nextDrafts = [...prevState.coordinateDrafts]; - nextDrafts[axisIndex] = null; - return { coordinateDrafts: nextDrafts }; - }); - } - /** - * Selection changes are a visual-only, wave-internal concern (highlight + gizmo, already - * handled inside the mixin) - the only reason React needs to know the indices at all is to - * drive the coordinate panel/toolbar. Without the bypass guard, this setState triggers a - * WaveComponent re-render with a freshly cloned structure prop, which componentDidUpdate - * sees as "changed" and reloads/rebuilds the entire scene on every single click or hover-driven - * selection - orphaning an in-progress drag's mesh reference (D3) and making selection - * sluggish on larger structures (R17). - * - * indices is an array of atomicIndex (D-4: multi-select) - empty for none, one entry for a - * single atom, 2+ for a group selection. - */ - handleSelectionChanged(indices) { - var _a; - const { activeTransformMode } = this.state; - const { onSelectionChanged } = this.props; - if ((_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave) { - this.WaveComponent.wave.bypassReloadViewer = true; - } - if (onSelectionChanged) - onSelectionChanged(indices); - // Rotate only makes sense for a group (it spins the selection about its centroid) - if - // the selection drops below 2 while rotate is active (e.g. a Shift-click deselect, or - // Delete removing atoms down to one), fall back to translate rather than leaving the - // toolbar showing a mode the gizmo can no longer meaningfully perform. - const nextTransformMode = activeTransformMode === "rotate" && indices.length < 2 ? "translate" : null; - this.setState({ - selectedAtomIndices: indices, - coordinateDrafts: [null, null, null], - elementDraft: null, - ...(nextTransformMode ? { activeTransformMode: nextTransformMode } : {}), - }, () => { - var _a, _b; - if (nextTransformMode && ((_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave)) { - this.WaveComponent.wave.setTransformMode(nextTransformMode); - } - if ((_b = this.WaveComponent) === null || _b === void 0 ? void 0 : _b.wave) { - this.WaveComponent.wave.bypassReloadViewer = false; - } - }); - } - handleSetTransformMode(mode) { - var _a; - this.setState({ activeTransformMode: mode }); - if ((_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave) { - this.WaveComponent.wave.setTransformMode(mode); - } - } - /** - * Places a new atom at the true center of the cell - (a+b+c)/2, the vector sum of the three - * lattice vectors halved - not the component-wise (ax/2, by/2, cz/2), which lands off-center - * or outside the cell entirely for non-orthogonal lattices (D22). If that position is - * already occupied (e.g. a second click with nothing else changed), nudges the candidate - * along the diagonal until it clears every existing atom by OCCUPIED_TOLERANCE, so repeated - * clicks don't silently stack coincident duplicates. - */ - handleAddAtom() { - var _a, _b; - const { material } = this.state; - const { editSessionOptions } = this.props; - if (!((_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave) || !((_b = material === null || material === void 0 ? void 0 : material.getLattice()) === null || _b === void 0 ? void 0 : _b.unitCell)) - return; - const { ax = 0, ay = 0, az = 0, bx = 0, by = 0, bz = 0, cx = 0, cy = 0, cz = 0, } = material.getLattice().unitCell; - const trueCenter = [(ax + bx + cx) / 2, (ay + by + cy) / 2, (az + bz + cz) / 2]; - const basis = material.getBasis(); - basis.toCartesian(); - const existingPositions = basis.coordinatesAsArray; - const OCCUPIED_TOLERANCE = 0.5; // Å; below any realistic bond length - const OFFSET_STEP = [0.3, 0.3, 0.3]; - const MAX_OFFSET_ATTEMPTS = 10; - const isOccupied = (position) => existingPositions.some((existing) => { - const dx = existing[0] - position[0]; - const dy = existing[1] - position[1]; - const dz = existing[2] - position[2]; - return Math.sqrt(dx * dx + dy * dy + dz * dz) < OCCUPIED_TOLERANCE; - }); - const candidate = [...trueCenter]; - let attempts = 0; - while (isOccupied(candidate) && attempts < MAX_OFFSET_ATTEMPTS) { - attempts += 1; - for (let axis = 0; axis < 3; axis += 1) { - candidate[axis] = trueCenter[axis] + OFFSET_STEP[axis] * attempts; - } - } - const element = (editSessionOptions === null || editSessionOptions === void 0 ? void 0 : editSessionOptions.defaultElement) || "Si"; - this.WaveComponent.wave.addAtom(element, candidate); - } - handleRemoveSelectedAtom() { - var _a; - if ((_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave) { - this.WaveComponent.wave.removeSelectedAtom(); - } - } - handleCloneSelectedAtoms() { - var _a; - if ((_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave) { - this.WaveComponent.wave.cloneSelectedAtoms(); - } - } - handleFocusCameraOnSelection() { - var _a; - if ((_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave) { - this.WaveComponent.wave.focusCameraOnSelection(); - } - } - /** - * Updates only the local draft string for the element field while it's focused - no commit, - * no history entry, no scene rebuild. Mirrors handleCoordinateDraftChange (D18). - */ - handleElementDraftChange(value) { - this.setState({ elementDraft: value }); - } - /** - * Commits the element draft (on blur/Enter) if it names a real element that differs from the - * current one, then clears the draft so the field reverts to showing the committed value. - * Operates directly on state.material like handleCoordinateChange, rather than through the - * mixin - this is a panel-typed edit, not a scene-gesture-driven one. Symbol casing is - * normalized (e.g. "si"/"SI" -> "Si") so the field isn't case-sensitive to use, then checked - * against PERIODIC_TABLE so a typo silently reverts instead of writing a bogus element. - */ - handleElementCommit() { - const { elementDraft, selectedAtomIndices, material } = this.state; - if (elementDraft !== null) { - const trimmed = elementDraft.trim(); - const symbol = trimmed.charAt(0).toUpperCase() + trimmed.slice(1).toLowerCase(); - if (selectedAtomIndices.length === 1 && symbol in PERIODIC_TABLE) { - const [selectedAtomIndex] = selectedAtomIndices; - const basis = material.getBasis(); - const { elements } = basis; - const currentEntry = elements[selectedAtomIndex]; - const currentSymbol = typeof currentEntry === "string" ? currentEntry : currentEntry === null || currentEntry === void 0 ? void 0 : currentEntry.value; - if (currentEntry && currentSymbol !== symbol) { - const newMaterial = material.clone(); - const newBasis = newMaterial.getBasis(); - const newElements = newBasis.elements; - newElements[selectedAtomIndex] = { - ...newElements[selectedAtomIndex], - value: symbol, - }; - newBasis.elements = newElements; - newMaterial.setBasis(newBasis.toJSON()); - this.handleStructureModified(newMaterial, "element-input"); - } - } - } - this.setState({ elementDraft: null }); - } - // TODO: reset the colors for other buttons in the panel on call to the function below - handleResetViewer() { - this.WaveComponent.initViewer(); - this._resetStateWaveComponent(); - } - handleTakeScreenshot() { - this.WaveComponent.wave.takeScreenshot(); - } - handleToggleOrbitControls() { - this.WaveComponent.wave.toggleOrbitControls(); - this._resetStateWaveComponent(); - } - handleToggleOrbitControlsAnimation() { - this.WaveComponent.wave.toggleOrbitControlsAnimation(); - this._resetStateWaveComponent(); - } - handleToggleAxes() { - this.WaveComponent.wave.toggleAxes(); - this._resetStateWaveComponent(); - } - _getWaveProperty(name) { - return this.WaveComponent && this.WaveComponent.wave[name]; - } - handleSetState(newState) { - this.setState(newState); - } - handleSetMeasurementSettingsForTypeInState(newMeasurementSettingsForType) { - const { measurementsSettings } = this.state; - const measurementSettingsHandler = new MeasurementSettingsHandler(measurementsSettings); - measurementSettingsHandler.updateMeasurementSettingsByType(newMeasurementSettingsForType); - const newMeasurementsSettings = measurementSettingsHandler.measurementsSettings; - this.setState({ measurementsSettings: newMeasurementsSettings }); - } - handleDeleteConnection() { - this.WaveComponent.wave.deleteConnection(); - } - handleResetMeasurements() { - var _a; - if ((_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave) { - this.WaveComponent.wave.resetAllMeasurements(); - } - } - handleToggleMeasurement(measurementMode) { - this.WaveComponent.wave.toggleMeasurementByType(measurementMode, this.handleSetMeasurementSettingsForTypeInState); - const newMeasurementsSettings = this.WaveComponent.wave.getMeasurementsSettings(); - this.setState({ measurementsSettings: newMeasurementsSettings }); - // Mirror of the edit-mode-entry guard in handleToggleEditMode (D20): activating a - // measurement mode force-exits edit mode. - const { isEditModeActive } = this.state; - const measurementsSettingsHandler = new MeasurementSettingsHandler(newMeasurementsSettings); - const isAnyMeasurementActive = [ - MEASUREMENT_MODES.DISTANCE, - MEASUREMENT_MODES.ANGLE, - MEASUREMENT_MODES.COORDINATE, - ].some((mode) => measurementsSettingsHandler.isMeasurementActiveByType(mode)); - if (isAnyMeasurementActive && isEditModeActive) { - this.handleToggleEditMode(); - } - } - handleViewerError(error) { - // Keep the message rather than only the fact of failure: a blank canvas with no reason is - // exactly what F8 was about. - this.setState({ viewerError: (error === null || error === void 0 ? void 0 : error.message) || String(error) }); - } - handleRetryViewer() { - const { viewerResetKey } = this.state; - this.setState({ viewerError: null, viewerResetKey: viewerResetKey + 1 }); - } - /** - * Which overlay state the canvas is in, or null for "showing a structure". Error wins over - * empty: if the build threw, the atom count is not evidence of anything. - */ - getViewerStatusKind() { - var _a, _b; - const { viewerError, material } = this.state; - if (viewerError) - return "error"; - if (!material) - return "empty"; - const atomCount = (_b = (_a = material.basis) === null || _a === void 0 ? void 0 : _a.elements) === null || _b === void 0 ? void 0 : _b.length; - if (!atomCount) - return "empty"; - return null; - } - /** - * Points the camera down a lattice vector. Reset View was the only camera command the viewer - * had (F12); axis views are a primary control in VESTA and CrystalMaker. - */ - handleViewAlongAxis(axis) { - var _a, _b, _c; - (_c = (_b = (_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave) === null || _b === void 0 ? void 0 : _b.setCameraAlongCellVector) === null || _c === void 0 ? void 0 : _c.call(_b, axis); - } - handleToggleKeyboardSheet() { - const { isKeyboardSheetOpen } = this.state; - this.setState({ isKeyboardSheetOpen: !isKeyboardSheetOpen }); - } - handleCloseKeyboardSheet() { - this.setState({ isKeyboardSheetOpen: false }); - } - handleOpenFigureExport() { - this.setState({ isFigureExportOpen: true }); - } - handleCloseFigureExport() { - this.setState({ isFigureExportOpen: false }); - } - /** - * Renders and downloads a figure (U-12). - * - * Reported either way through the status bar's live region. A download is one of the few actions - * with no visible effect inside the app at all - the browser may put the file somewhere the user - * never sees - and an over-large request can exhaust the GL context, where "nothing happened" is - * the least useful possible outcome. Deliberately not routed through handleViewerError: the - * viewer is still fine, and blanking it behind an error card would be a worse lie than the - * failure itself. - */ - handleExportFigure(options) { - try { - const fileName = this.WaveComponent.wave.exportFigure(options); - this._showHint(`Saved ${fileName}`); - } - catch (error) { - this._showHint(`Figure export failed: ${(error === null || error === void 0 ? void 0 : error.message) || error}`); - } - } - /** - * Canvas size in pixels, for the export dialog's "On-screen" preset and to keep every other - * preset at the canvas aspect ratio. Zeroes are a valid answer (a not-yet-measured container); - * getFigureResolution falls back to 4:3 rather than dividing by zero. - */ - getViewportSize() { - var _a; - const wave = (_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave; - return { width: (wave === null || wave === void 0 ? void 0 : wave.WIDTH) || 0, height: (wave === null || wave === void 0 ? void 0 : wave.HEIGHT) || 0 }; - } - /** GL-reported render limit, or undefined so the dialog uses its own conservative default. */ - getMaxFigureDimension() { - var _a, _b, _c; - return (_c = (_b = (_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave) === null || _b === void 0 ? void 0 : _b.getMaxFigureDimension) === null || _c === void 0 ? void 0 : _c.call(_b); - } - handleDisplayUnitsChange(displayUnits) { - this.setState({ displayUnits }); - } - /** - * The selected atom's coordinates expressed in `displayUnits`, or null when that is already the - * material's own unit (in which case the stored values are shown as-is). - * - * Converts the single touched point through `basis.cell`, the same primitive the delta-based - * edit path uses - never `Basis.toCartesian()/toCrystal()`, which rewrites every atom. - */ - getDisplayCoordinates() { - var _a, _b, _c, _d; - const { material, selectedAtomIndices, displayUnits } = this.state; - if (selectedAtomIndices.length !== 1 || !displayUnits) - return null; - const nativeUnits = ((_a = material === null || material === void 0 ? void 0 : material.basis) === null || _a === void 0 ? void 0 : _a.units) === "cartesian" ? "cartesian" : "crystal"; - if (displayUnits === nativeUnits) - return null; - const point = (_d = (_c = (_b = material === null || material === void 0 ? void 0 : material.basis) === null || _b === void 0 ? void 0 : _b.coordinates) === null || _c === void 0 ? void 0 : _c[selectedAtomIndices[0]]) === null || _d === void 0 ? void 0 : _d.value; - if (!Array.isArray(point)) - return null; - try { - const { cell } = material.getBasis(); - return displayUnits === "cartesian" - ? cell.convertPointToCartesian([...point]) - : cell.convertPointToCrystal([...point]); - } - catch (error) { - // A cell that cannot convert is not worth breaking the panel over; fall back to - // showing the stored values. - return null; - } - } - /** - * The armed measurement mode, or null. Read straight off the state the managers already push - * through updateState on every click, so the mode pill and the status-bar readout follow the - * measurement without any new callback out of the mixin. - */ - getActiveMeasurement() { - const { measurementsSettings } = this.state; - return new MeasurementSettingsHandler(measurementsSettings).getActiveMeasurement(); - } - /** - * Element symbol of the single selected atom, or "" for none/multiple. The status bar and the - * edit panel both need it, and the basis stores an element as either a bare symbol or a - * `{ value }` cell depending on the fixture - hence the shared normalizer. - */ - getSelectedElementSymbol() { - var _a, _b; - const { selectedAtomIndices, material } = this.state; - if (selectedAtomIndices.length !== 1) - return ""; - const [index] = selectedAtomIndices; - return normalizeElement((_b = (_a = material === null || material === void 0 ? void 0 : material.basis) === null || _a === void 0 ? void 0 : _a.elements) === null || _b === void 0 ? void 0 : _b[index]); - } - /** - * Selects every atom of one element - the status bar's composition chips double as a - * select-all control. Routed through the mixin's reselectAtomsByIndices so it goes through - * the same single source of truth for onSelectionChanged as every other selection path. - */ - handleSelectElement(elementSymbol) { - var _a, _b; - const wave = (_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave; - const { material } = this.state; - if (!(wave === null || wave === void 0 ? void 0 : wave.reselectAtomsByIndices)) - return; - const elements = (_b = material === null || material === void 0 ? void 0 : material.basis) === null || _b === void 0 ? void 0 : _b.elements; - if (!Array.isArray(elements)) - return; - const indices = elements.reduce((accumulated, entry, index) => { - if (normalizeElement(entry) === elementSymbol) - accumulated.push(index); - return accumulated; - }, []); - if (!indices.length) - return; - wave.reselectAtomsByIndices(indices); - wave.render(); - } - /** - * Returns a cover div to cover the area and prevent user interaction with component - */ - renderCoverDiv() { - const style = { - position: "absolute", - height: "100%", - width: "100%", - }; - const { isInteractive } = this.state; - if (isInteractive) - style.display = "none"; - return _jsx("div", { className: "atom-view-cover", style: style }); - } - renderWaveComponent() { - const { isConventionalCellShown, viewerSettings, viewerTriggerResize, boundaryConditions, material, } = this.state; - const materialCopy = this.getPrimitiveOrConventionalMaterial(material, isConventionalCellShown); - const isDrawBondsEnabled = this._getWaveProperty("isDrawBondsEnabled") || false; - return (_jsx(WaveComponent, { ref: (el) => { - this.WaveComponent = el; - }, triggerHandleResize: viewerTriggerResize, isConventionalCellShown: isConventionalCellShown, isDrawBondsEnabled: isDrawBondsEnabled, isViewAdjustable: viewerSettings.isViewAdjustable, structure: materialCopy, boundaryConditions: boundaryConditions, cell: materialCopy.getLattice().unitCell, name: materialCopy.name, settings: { - ...viewerSettings, - onStructureModified: this.handleStructureModified, - onSelectionChanged: this.handleSelectionChanged, - } })); - } - // TODO: move in the toolbar component when it's created - /** - * On/off state for a menu toggle, plus its hotkey in a fixed slot. Replaces the previous - * grey-checkmark-means-off rendering, which used one shape for both answers (F2), and takes - * the key out of the label text so every row advertises it the same way (F3). - */ - // eslint-disable-next-line class-methods-use-this - getToggleIndicator(isActive, hotKey) { - return _jsx(ToggleIndicator, { isActive: Boolean(isActive), hotKey: hotKey }); - } - /** - * The View items people flip repeatedly rather than set once, promoted out of a dropdown that - * closes on every choice (U-7). Same state and same handlers as the menu entries - this is a - * shortcut, not a move, so the menu keeps working exactly as before. - */ - getQuickToggleItems() { - const areLabelsVisibleByType = (type) => { var _a, _b, _c; return (_c = (_b = (_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave) === null || _b === void 0 ? void 0 : _b.areLabelsVisibleByType) === null || _c === void 0 ? void 0 : _c.call(_b, type); }; - const keys = settings.hotKeysConfig; - return [ - { - id: "bonds", - title: "Bonds", - hotKey: keys.toggleBonds, - isActive: Boolean(this._getWaveProperty("isDrawBondsEnabled")), - icon: _jsx(Dehaze, {}), - onToggle: this.handleToggleBonds, - }, - { - id: "element-labels", - title: "Element labels", - hotKey: keys.toggleElementLabels, - isActive: Boolean(areLabelsVisibleByType && areLabelsVisibleByType("element")), - icon: _jsx(Spellcheck, {}), - onToggle: this.handleToggleElementLabels, - }, - { - id: "axes", - title: "Axes", - isActive: Boolean(this._getWaveProperty("areAxesEnabled")), - icon: _jsx(GpsFixed, {}), - onToggle: this.handleToggleAxes, - }, - { - id: "orthographic", - title: "Orthographic camera", - isActive: Boolean(this._getWaveProperty("isCameraOrthographic")), - icon: _jsx(SwitchCamera, {}), - onToggle: this.handleToggleOrthographicCamera, - }, - { - id: "orbit", - title: "Rotate / zoom", - hotKey: keys.toggleOrbitControls, - isActive: Boolean(this._getWaveProperty("areOrbitControlsEnabled")), - icon: _jsx(ThreeDRotation, {}), - onToggle: this.handleToggleOrbitControls, - }, - ]; - } - getToolbarConfig() { - const toolbarConfig = [ - { - id: "View", - title: "View", - header: "View", - leftIcon: _jsx(RemoveRedEye, {}), - actions: this.getViewSettingsActions(), - onClick: () => this.handleToggleToolbarMenu("view-settings"), - }, - { - id: "Parameters", - title: "Parameters", - header: "Parameters", - leftIcon: _jsx(Settings, {}), - contentObject: this.getParametersActions(), - onClick: () => this.handleToggleToolbarMenu("parameters"), - }, - { - id: "measurements", - title: "Measurements", - header: "Measurements", - leftIcon: _jsx(SquareFootIcon, {}), - actions: this.getMeasurementsActions(), - onClick: () => this.handleToggleToolbarMenu("measurements"), - }, - { - id: "Export", - title: "Export", - header: "Export", - leftIcon: _jsx(ImportExport, {}), - actions: this.getExportActions(), - onClick: () => this.handleToggleToolbarMenu("export"), - }, - ]; - const { editable } = this.props; - const { isEditModeActive } = this.state; - // History outlives edit mode (F6), so undo/redo need to be reachable outside it. Inside - // edit mode the edit toolbar already carries them, so they are not duplicated here. - if (!isEditModeActive && (this.canUndo() || this.canRedo())) { - toolbarConfig.push({ - id: "Undo", - title: "Undo", - leftIcon: _jsx(Undo, {}), - disabled: !this.canUndo(), - onClick: this.handleUndo, - }, { - id: "Redo", - title: "Redo", - leftIcon: _jsx(Redo, {}), - disabled: !this.canRedo(), - onClick: this.handleRedo, - }); - } - if (editable) { - toolbarConfig.splice(4, 0, { - id: "3DEdit", - title: isEditModeActive - ? "Exit Edit" - : `Edit [${settings.hotKeysConfig.toggleEditMode.toUpperCase()}]`, - leftIcon: _jsx(Edit, { color: isEditModeActive ? "primary" : "inherit" }), - onClick: this.handleToggleEditMode, - }); - } - return toolbarConfig; - } - async handleStartGifRecording(downloadPath, rotationSpeed = 60, frameDuration = 0.05) { - await this.WaveComponent.wave.takeGifScreenshot({ - downloadPath, - rotationSpeed, - frameDuration, - }); - console.log("Recorded gif"); - } - /** - * The edit surface: an icon strip of tools plus a selection inspector, side by side. - * - * The container is bounded top and bottom (`bottom` clears the status bar) and the inspector - * scrolls inside it. That is the structural half of the F1 fix - the previous single 84 px - * column was ~600 px tall with no scroll, so on a short viewer the coordinate fields were cut - * off and unreachable rather than merely cramped. - */ - renderEditSurface() { - var _a, _b, _c, _d; - const { activeTransformMode, selectedAtomIndices, material, coordinateDrafts, elementDraft, displayUnits, } = this.state; - const { editSessionOptions } = this.props; - const [selectedAtomIndex] = selectedAtomIndices; - let selectedCoordinates = [0, 0, 0]; - if (selectedAtomIndices.length === 1) { - const selectedAtom = (_b = (_a = material === null || material === void 0 ? void 0 : material.basis) === null || _a === void 0 ? void 0 : _a.coordinates) === null || _b === void 0 ? void 0 : _b[selectedAtomIndex]; - if (selectedAtom) { - selectedCoordinates = Array.isArray(selectedAtom) - ? selectedAtom - : selectedAtom.value || selectedAtom; - } - } - const selectedElement = this.getSelectedElementSymbol(); - const elementColor = selectedElement - ? ((_c = settings.elementColors) === null || _c === void 0 ? void 0 : _c[selectedElement]) || settings.defaultColor - : settings.defaultColor; - return (_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "flex-start", justifyContent: "flex-end", sx: { - position: "absolute", - top: "1em", - right: "1em", - left: "1em", - // Clears the status bar, so neither surface can ever sit under the other. - bottom: "3em", - pointerEvents: "none", - "& > *": { pointerEvents: "auto" }, - }, children: [_jsx(SelectionInspector, { selectedAtomIndices: selectedAtomIndices, selectedElement: selectedElement, selectedCoordinates: selectedCoordinates, materialUnits: (_d = material === null || material === void 0 ? void 0 : material.basis) === null || _d === void 0 ? void 0 : _d.units, displayUnits: displayUnits, displayCoordinates: this.getDisplayCoordinates(), elementColor: elementColor, coordinateDrafts: coordinateDrafts, elementDraft: elementDraft, onDisplayUnitsChange: this.handleDisplayUnitsChange, onCoordinateDraftChange: this.handleCoordinateDraftChange, onCoordinateCommit: this.handleCoordinateCommit, onElementDraftChange: this.handleElementDraftChange, onElementCommit: this.handleElementCommit }), _jsx(EditToolbar, { activeTransformMode: activeTransformMode, selectedCount: selectedAtomIndices.length, defaultElement: (editSessionOptions === null || editSessionOptions === void 0 ? void 0 : editSessionOptions.defaultElement) || "Si", canUndo: this.canUndo(), canRedo: this.canRedo(), onSetTransformMode: this.handleSetTransformMode, onAddAtom: this.handleAddAtom, onCloneSelected: this.handleCloneSelectedAtoms, onRemoveSelected: this.handleRemoveSelectedAtom, onFocusCamera: this.handleFocusCameraOnSelection, onUndo: this.handleUndo, onRedo: this.handleRedo })] })); - } - renderViewerWithToolbars() { - const { isInteractive, isEditModeActive, material, selectedAtomIndices, isKeyboardSheetOpen, isFigureExportOpen, viewerError, viewerResetKey, lastActionHint, } = this.state; - const { editable } = this.props; - const activeMeasurement = this.getActiveMeasurement(); - const viewportSize = this.getViewportSize(); - // Every surface that drives the wave instance is gated on this, not just on - // isInteractive: after a caught render failure this.WaveComponent is null, and most - // handlers dereference it unguarded, so a click would throw from an event handler where - // no error boundary can catch it. The status bar is exempt - it only reads the material. - const isViewerUsable = isInteractive && !viewerError; - return (_jsxs("div", { className: "wave-component-holder", style: { position: "relative", height: "100%" }, children: [this.renderCoverDiv(), _jsx(IconsToolbar, { toolbarConfig: this.getToolbarConfig(), - // A failed viewer leaves this.WaveComponent null, and most menu handlers - // dereference it without a guard - so a click after an error would throw - // again, this time from an event handler where no boundary can catch it. - // IconsToolbar already hides everything but the power button when not - // interactive, which is exactly the reachable surface we want here: power - // off/on, or Retry from the status card. - isInteractive: isViewerUsable, handleToggleInteractive: this.handleToggleInteractive }), _jsx(ViewerErrorBoundary, { onError: this.handleViewerError, resetKey: viewerResetKey, children: this.renderWaveComponent() }), _jsx(ViewerStatus, { kind: this.getViewerStatusKind(), message: viewerError, onRetry: this.handleRetryViewer }), isViewerUsable && isEditModeActive && this.renderEditSurface(), isViewerUsable && (_jsx(ModePill, { isEditModeActive: isEditModeActive, activeMeasurement: activeMeasurement, onExitEditMode: this.handleToggleEditMode, onExitMeasurement: this.handleToggleMeasurement, isOrbitEnabled: Boolean(this._getWaveProperty("areOrbitControlsEnabled")) })), isViewerUsable && _jsx(QuickToggles, { items: this.getQuickToggleItems() }), isInteractive && (_jsx(StatusBar, { material: material, selectedAtomIndices: selectedAtomIndices, selectedElement: this.getSelectedElementSymbol(), measurement: formatMeasurementValue(activeMeasurement), lastActionHint: lastActionHint, - // Chip-click selection only has machinery to act on in edit mode, so - // outside it the chips stay a pure legend rather than a dead control. - onSelectElement: isViewerUsable && isEditModeActive - ? this.handleSelectElement - : undefined })), _jsx(KeyboardSheet, { isOpen: isInteractive && isKeyboardSheetOpen, onClose: this.handleCloseKeyboardSheet, editable: editable }), isViewerUsable && (_jsx(FigureExportDialog, { isOpen: isFigureExportOpen, onClose: this.handleCloseFigureExport, onExport: this.handleExportFigure, viewportWidth: viewportSize.width, viewportHeight: viewportSize.height, - // Queried only while the dialog is open: it reads three GL parameters, and - // this component re-renders on every selection change, drag commit and - // transient hint. Undefined lets the dialog fall back to its own cap. - maxDimension: isFigureExportOpen ? this.getMaxFigureDimension() : undefined, isCameraOrthographic: Boolean(this._getWaveProperty("isCameraOrthographic")) }))] })); - } - render() { - const { isStandalone } = this.props; - return (_jsx(ThemeProvider, { theme: DarkMaterialUITheme, children: _jsx(ScopedCssBaseline, { enableColorScheme: true, style: { height: "100%" }, children: isStandalone ? (_jsx(AlertProvider, { children: this.renderViewerWithToolbars() })) : (this.renderViewerWithToolbars()) }) })); - } -} -ThreeDEditor.propTypes = { - material: PropTypes.instanceOf(Made.Material).isRequired, - editable: PropTypes.bool, - isConventionalCellShown: PropTypes.bool, // eslint-disable-next-line react/forbid-prop-types - boundaryConditions: PropTypes.object, - onUpdate: PropTypes.func, - // Fires once per committed edit, like onUpdate, but also carries {source} - one of "drag", - // "gizmo", "coordinate-input", "element-input", "add", "remove", "clone", "undo", "redo" - so - // a host can record its own history without double-counting onUpdate's every-edit cadence - // against its own undo/redo actions (spec §6.2). - onEditCommit: PropTypes.func, - // Fires whenever edit mode is toggled on/off, so a host can disable conflicting UI. - onEditModeChanged: PropTypes.func, - // Fires with the current array of selected atomicIndex whenever the selection changes - // (empty for none, 2+ for a group) - spec §6.2's data source for a host's selection-info UI. - onSelectionChanged: PropTypes.func, - isStandalone: PropTypes.bool, - // eslint-disable-next-line react/forbid-prop-types - initialViewSettings: PropTypes.object, - // Per-session editing defaults, e.g. { defaultElement: "Si" } for the Add Atom button. - // eslint-disable-next-line react/forbid-prop-types - editSessionOptions: PropTypes.object, -}; -ThreeDEditor.defaultProps = { - boundaryConditions: {}, - isConventionalCellShown: false, - onUpdate: undefined, - onEditCommit: undefined, - onEditModeChanged: undefined, - onSelectionChanged: undefined, - editable: false, - isStandalone: false, - initialViewSettings: {}, - editSessionOptions: {}, -}; diff --git a/dist/components/ToggleIndicator.d.ts b/dist/components/ToggleIndicator.d.ts deleted file mode 100644 index 86457304..00000000 --- a/dist/components/ToggleIndicator.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface ToggleIndicatorProps { - isActive?: boolean; - /** Single-character hotkey from `settings.hotKeysConfig`, if the item has one. */ - hotKey?: string; -} -declare function ToggleIndicator({ isActive, hotKey }: ToggleIndicatorProps): import("react/jsx-runtime").JSX.Element; -export default ToggleIndicator; diff --git a/dist/components/ToggleIndicator.js b/dist/components/ToggleIndicator.js deleted file mode 100644 index bba6deba..00000000 --- a/dist/components/ToggleIndicator.js +++ /dev/null @@ -1,74 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import { useTheme } from "@mui/material/styles"; -import Typography from "@mui/material/Typography"; -/** - * The on/off state of a menu toggle, plus its hotkey. - * - * This replaces `getCheckmark`, which drew every inactive item as a *grey checkmark* (finding - * F2). A grey ✓ reads as "checked but disabled", not "off" - the one shape that means "yes" was - * carrying both answers, separated only by colour, which also made the state invisible to anyone - * who cannot compare two greens. - * - * A switch has exactly one reading. It is drawn rather than built from MUI's `Switch` on purpose: - * the menu row is already the control, and nesting a real form control inside it would put a - * second focusable, separately-clickable target in the same row. This is decorative, with the - * state exposed as text for screen readers instead. - * - * The hotkey moves out of the label into a fixed slot next to it (F3). Spelled inside the label, - * some rows read "Bonds [B]" and others just "Axes", and nothing keeps that consistent. - * - * That slot is *always* rendered, and the whole indicator is a fixed width. Omitting the keycap on - * rows without a hotkey let each row size itself, so the switches landed at slightly different - * offsets down the View menu - close enough to look like a rendering fault rather than a layout one. - * A menu of toggles reads as a column, and a column has to line up. - */ -/** Keycap slot width. Fixed, so a row without a hotkey still reserves it. */ -const KEY_SLOT_WIDTH = "1.35rem"; -function ToggleIndicator({ isActive = false, hotKey }) { - const theme = useTheme(); - return (_jsxs(Stack, { direction: "row", alignItems: "center", justifyContent: "flex-end", spacing: 1, "data-name": "ToggleIndicator", "data-active": isActive ? "true" : "false", children: [_jsx(Box, { component: "kbd", "data-name": "ToggleIndicatorKey", "aria-hidden": hotKey ? undefined : true, sx: { - fontFamily: "monospace", - fontSize: "0.68rem", - lineHeight: 1.5, - width: KEY_SLOT_WIDTH, - flexShrink: 0, - textAlign: "center", - borderRadius: "3px", - border: `1px solid ${theme.palette.divider}`, - color: theme.palette.text.secondary, - visibility: hotKey ? "visible" : "hidden", - }, children: hotKey ? hotKey.toUpperCase() : "" }), _jsx(Box, { "aria-hidden": "true", "data-name": "ToggleIndicatorSwitch", sx: { - position: "relative", - width: 28, - height: 14, - borderRadius: "7px", - flexShrink: 0, - backgroundColor: isActive - ? theme.palette.primary.main - : theme.palette.action.disabled, - transition: theme.transitions.create("background-color", { - duration: theme.transitions.duration.shortest, - }), - }, children: _jsx(Box, { sx: { - position: "absolute", - top: 2, - left: isActive ? 16 : 2, - width: 10, - height: 10, - borderRadius: "50%", - backgroundColor: theme.palette.common.white, - transition: theme.transitions.create("left", { - duration: theme.transitions.duration.shortest, - }), - } }) }), _jsx(Typography, { variant: "caption", sx: { - position: "absolute", - width: 1, - height: 1, - overflow: "hidden", - clip: "rect(0 0 0 0)", - whiteSpace: "nowrap", - }, children: isActive ? "on" : "off" })] })); -} -export default ToggleIndicator; diff --git a/dist/components/ViewerErrorBoundary.d.ts b/dist/components/ViewerErrorBoundary.d.ts deleted file mode 100644 index 49e12764..00000000 --- a/dist/components/ViewerErrorBoundary.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Catches a throw from the viewer subtree so a failed scene build becomes a message rather than a - * blank canvas or an unmounted tree. - * - * This matters more than it used to: `WaveComponent.reloadViewer` deliberately no longer wraps its - * work in a `try/catch → console.warn` (status-doc S-1), because that hid real render failures - * behind a stale viewer. With the catch gone, an exception from `setStructure`/`rebuildScene` - * propagates out of `componentDidUpdate` — which React turns into an unmount of the whole tree - * unless a boundary stops it here. - * - * The boundary reports upward and renders nothing itself; the parent owns what the user sees, so - * the toolbars stay mounted and the failure can be retried. Remounting is driven by the `resetKey` - * prop rather than by internal state, so the parent decides when a retry happens. - */ -export class ViewerErrorBoundary extends React.Component { - static getDerivedStateFromError(): { - hasError: boolean; - }; - constructor(props: any); - state: { - hasError: boolean; - }; - componentDidUpdate(previousProps: any): void; - componentDidCatch(error: any, errorInfo: any): void; - render(): any; -} -export namespace ViewerErrorBoundary { - namespace propTypes { - let children: PropTypes.Requireable; - let onError: PropTypes.Requireable<(...args: any[]) => any>; - let resetKey: PropTypes.Requireable; - } - namespace defaultProps { - let children_1: null; - export { children_1 as children }; - let onError_1: undefined; - export { onError_1 as onError }; - let resetKey_1: number; - export { resetKey_1 as resetKey }; - } -} -export default ViewerErrorBoundary; -import React from "react"; -import PropTypes from "prop-types"; diff --git a/dist/components/ViewerErrorBoundary.js b/dist/components/ViewerErrorBoundary.js deleted file mode 100644 index a6091796..00000000 --- a/dist/components/ViewerErrorBoundary.js +++ /dev/null @@ -1,59 +0,0 @@ -import PropTypes from "prop-types"; -import React from "react"; -/** - * Catches a throw from the viewer subtree so a failed scene build becomes a message rather than a - * blank canvas or an unmounted tree. - * - * This matters more than it used to: `WaveComponent.reloadViewer` deliberately no longer wraps its - * work in a `try/catch → console.warn` (status-doc S-1), because that hid real render failures - * behind a stale viewer. With the catch gone, an exception from `setStructure`/`rebuildScene` - * propagates out of `componentDidUpdate` — which React turns into an unmount of the whole tree - * unless a boundary stops it here. - * - * The boundary reports upward and renders nothing itself; the parent owns what the user sees, so - * the toolbars stay mounted and the failure can be retried. Remounting is driven by the `resetKey` - * prop rather than by internal state, so the parent decides when a retry happens. - */ -export class ViewerErrorBoundary extends React.Component { - constructor(props) { - super(props); - this.state = { hasError: false }; - } - static getDerivedStateFromError() { - return { hasError: true }; - } - componentDidUpdate(previousProps) { - const { resetKey } = this.props; - const { hasError } = this.state; - // A new resetKey is the parent saying "try again"; clear the caught state so children - // remount. Guarded on hasError so an unrelated re-render cannot loop setState. - if (hasError && resetKey !== previousProps.resetKey) { - this.setState({ hasError: false }); - } - } - componentDidCatch(error, errorInfo) { - const { onError } = this.props; - if (onError) - onError(error, errorInfo); - } - render() { - const { hasError } = this.state; - const { children } = this.props; - // Rendering the broken subtree again would throw again; the parent shows the message. - return hasError ? null : children; - } -} -ViewerErrorBoundary.propTypes = { - children: PropTypes.node, - /** Called once per caught error, with React's own error and componentStack. */ - onError: PropTypes.func, - /** Change this to clear a caught error and remount the children. */ - // eslint-disable-next-line react/forbid-prop-types - resetKey: PropTypes.any, -}; -ViewerErrorBoundary.defaultProps = { - children: null, - onError: undefined, - resetKey: 0, -}; -export default ViewerErrorBoundary; diff --git a/dist/components/ViewerStatus.d.ts b/dist/components/ViewerStatus.d.ts deleted file mode 100644 index 025b7b79..00000000 --- a/dist/components/ViewerStatus.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * The viewer had no loading, empty, or error state at all (finding F8): `LoadingIndicator`, - * `AlertDialog` and `ModalDialog` all sat in the tree with zero call sites. A structure that - * failed to render left an unexplained blank `#202020` canvas, and since `reloadViewer` no longer - * swallows exceptions into a `console.warn` (status-doc S-1), such a failure now surfaces as a - * thrown error with nothing to catch it. - * - * These states render as an overlay card rather than a modal on purpose. A render failure is not - * a decision the user has to make, so blocking the whole component behind a dialog would take - * away the toolbar they need to recover with - they want to know why the canvas is blank and be - * able to retry. `AlertDialog` remains the right shape for a destructive confirmation and is - * still unused; it should be deleted or moved to cove rather than given a contrived caller. - */ -export type ViewerStatusKind = "loading" | "empty" | "error"; -export interface ViewerStatusProps { - kind?: ViewerStatusKind | null; - /** Why the render failed, shown verbatim so the reason is not lost. */ - message?: string | null; - onRetry?: () => void; -} -declare function ViewerStatus(props: ViewerStatusProps): import("react/jsx-runtime").JSX.Element | null; -export default ViewerStatus; diff --git a/dist/components/ViewerStatus.js b/dist/components/ViewerStatus.js deleted file mode 100644 index 71562c47..00000000 --- a/dist/components/ViewerStatus.js +++ /dev/null @@ -1,52 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import { LoadingIndicator } from "./LoadingIndicator"; -const COPY = { - loading: { title: "Building the structure…", body: "" }, - empty: { - title: "Nothing to show", - body: "This structure has no atoms. Add one from the edit toolbar, or undo the last removal.", - }, - error: { - title: "Couldn't render this structure", - body: "The viewer failed while building the scene. The structure itself is unchanged.", - }, -}; -function ViewerStatus(props) { - const { kind = null, message = null, onRetry } = props; - if (!kind) - return null; - const copy = COPY[kind]; - return (_jsx(Stack, { "data-name": `ViewerStatus-${kind}`, alignItems: "center", justifyContent: "center", spacing: 1.5, - // Loading covers the canvas so a half-built scene does not read as the result; the - // other two leave the toolbars reachable, since recovering needs them. - sx: { - position: "absolute", - inset: 0, - pointerEvents: "none", - zIndex: 2, - backgroundColor: kind === "loading" ? "rgba(32, 32, 32, 0.6)" : "transparent", - }, children: _jsxs(Stack, { alignItems: "center", spacing: 1, sx: { - pointerEvents: "auto", - maxWidth: "26em", - px: 3, - py: 2, - textAlign: "center", - borderRadius: 1, - backgroundColor: "background.paper", - boxShadow: 4, - }, children: [kind === "loading" && _jsx(LoadingIndicator, {}), _jsx(Typography, { variant: "subtitle2", children: copy.title }), copy.body && (_jsx(Typography, { variant: "caption", color: "text.secondary", children: copy.body })), kind === "error" && message && (_jsx(Box, { component: "pre", "data-name": "ViewerStatusMessage", sx: { - m: 0, - maxWidth: "100%", - overflowX: "auto", - fontFamily: "monospace", - fontSize: "0.72rem", - textAlign: "left", - color: "error.main", - whiteSpace: "pre-wrap", - }, children: message })), kind === "error" && onRetry && (_jsx(Button, { size: "small", variant: "outlined", onClick: onRetry, "data-name": "ViewerStatusRetry", children: "Retry" }))] }) })); -} -export default ViewerStatus; diff --git a/dist/components/WaveComponent.d.ts b/dist/components/WaveComponent.d.ts deleted file mode 100644 index 28e234f3..00000000 --- a/dist/components/WaveComponent.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -export class WaveComponent extends React.Component { - constructor(props: any); - state: { - isFullscreen: boolean; - }; - componentDidMount(): void; - componentDidUpdate(prevProps: any, prevState: any, snapshot: any): void; - componentWillUnmount(): void; - _resizeTransitionTimeout: any; - shouldViewerAdjust(prevProps: any): boolean; - _cleanViewer(): void; - initViewer(): void; - wave: Wave | undefined; - _handleResizeTransition(): void; - reloadViewer(createBondsAsync: any): void; - render(): import("react/jsx-runtime").JSX.Element; - rendererDomElement: HTMLDivElement | null | undefined; -} -export namespace WaveComponent { - namespace propTypes { - let triggerHandleResize: PropTypes.Validator; - let settings: PropTypes.Validator; - let structure: PropTypes.Validator; - let cell: PropTypes.Validator; - let boundaryConditions: PropTypes.Validator; - let isConventionalCellShown: PropTypes.Validator; - let isDrawBondsEnabled: PropTypes.Validator; - let isViewAdjustable: PropTypes.Validator; - } -} -import React from "react"; -import { Wave } from "../wave"; -import PropTypes from "prop-types"; diff --git a/dist/components/WaveComponent.js b/dist/components/WaveComponent.js deleted file mode 100644 index 14059e6a..00000000 --- a/dist/components/WaveComponent.js +++ /dev/null @@ -1,134 +0,0 @@ -import { jsx as _jsx } from "react/jsx-runtime"; -import PropTypes from "prop-types"; -import React from "react"; -import { Wave } from "../wave"; -/* - * Wrapper component for materials visualizer. Uses Wave class to render a material structure. - * See below for property description. - */ -export class WaveComponent extends React.Component { - constructor(props) { - super(props); - this.state = { - // eslint-disable-next-line react/no-unused-state - isFullscreen: false, - }; - } - componentDidMount() { - this.initViewer(); - } - // eslint-disable-next-line no-unused-vars - componentDidUpdate(prevProps, prevState, snapshot) { - const { structure: prevStructure, settings: prevSettings, isConventionalCellShown: prevIsConventionalCellShown, isDrawBondsEnabled: prevIsDrawBondsEnabled, } = prevProps; - const { settings, structure, triggerHandleResize, isConventionalCellShown, isViewAdjustable, isDrawBondsEnabled, } = this.props; - if (triggerHandleResize) - this._handleResizeTransition(); - if (this.wave && !this.wave.bypassReloadViewer) { - // recreate bonds asynchronously if structure is changed. - this.reloadViewer(prevStructure.hash !== structure.hash || - prevSettings.chemicalConnectivityFactor !== - settings.chemicalConnectivityFactor || - prevIsConventionalCellShown !== isConventionalCellShown || - prevIsDrawBondsEnabled !== isDrawBondsEnabled); - } - if (this.shouldViewerAdjust(prevProps) && isViewAdjustable) { - this.wave.adjustCamerasAndOrbitControlsToCell(); - this.wave.render(); - } - } - componentWillUnmount() { - var _a; - if (this._resizeTransitionTimeout) { - clearTimeout(this._resizeTransitionTimeout); - this._resizeTransitionTimeout = null; - } - (_a = this.wave) === null || _a === void 0 ? void 0 : _a.dispose(); - } - shouldViewerAdjust(prevProps) { - const { cell } = this.props; - const { cell: prevCell } = prevProps; - const EPSILON = 1e-5; // Å; well below any physically meaningful lattice difference - if (cell.units !== prevCell.units) - return true; - return ["ax", "ay", "az", "bx", "by", "bz", "cx", "cy", "cz"].some((key) => { var _a, _b; return Math.abs(((_a = cell[key]) !== null && _a !== void 0 ? _a : 0) - ((_b = prevCell[key]) !== null && _b !== void 0 ? _b : 0)) > EPSILON; }); - } - _cleanViewer() { - const el = this.rendererDomElement; - while (el.firstChild) { - el.removeChild(el.firstChild); - } - } - initViewer() { - var _a; - // A prior Wave instance (e.g. from a "Reset View" re-init) must release its WebGL - // context, resize observer, and editor listeners before a new one is constructed for - // the same container, otherwise each reset leaks a full renderer. - (_a = this.wave) === null || _a === void 0 ? void 0 : _a.dispose(); - this._cleanViewer(); - const { structure, cell, settings, boundaryConditions } = this.props; - this.wave = new Wave({ - DOMElement: this.rendererDomElement, - structure, - cell, - settings, - boundaryConditions, - }); - // The height of the dom element is initially zero as css is loaded after component is rendered, hence below. - this._handleResizeTransition(); - } - _handleResizeTransition() { - // TODO: use standard recommended way - // This is a workaround: OrbitControls in Wave.js listens to resize events properly, but fails to resize the - // renderer component on fullscreen event. Here we explicitly do that and wait for the event to finish, assuming - // that 500 milliseconds is enough. - // - // The handle is retained so componentWillUnmount can cancel it: unmounting inside the - // 500ms window otherwise ran handleResize() against an already-disposed renderer. - // Any pending transition is superseded rather than stacked, since only the latest - // container size matters. - if (this._resizeTransitionTimeout) - clearTimeout(this._resizeTransitionTimeout); - this._resizeTransitionTimeout = setTimeout(() => { - var _a; - this._resizeTransitionTimeout = null; - (_a = this.wave) === null || _a === void 0 ? void 0 : _a.handleResize(); - }, 500); - } - reloadViewer(createBondsAsync) { - // Deliberately not wrapped in try/catch. This used to swallow every exception into a - // console.warn, justified by tests having no WebGL - but the suite now renders through - // a real headless-gl context, so the only thing the catch achieved in production was - // hiding genuine render failures behind a stale viewer and a console message. - const { settings, structure, boundaryConditions, cell } = this.props; - this.wave.updateSettings(settings); - this.wave.setStructure(structure); - this.wave.boundaryConditions = boundaryConditions; - this.wave.setCell(cell); - if (createBondsAsync) - this.wave.createBondsAsync(); - this.wave.rebuildScene(); - } - render() { - return (_jsx("div", { id: this.id, className: "three-renderer", ref: (el) => { - this.rendererDomElement = el; - } })); - } -} -WaveComponent.propTypes = { - // Whether to trigger handleResizeTransition() on update - triggerHandleResize: PropTypes.bool.isRequired, - // Wave settings - // eslint-disable-next-line react/forbid-prop-types - settings: PropTypes.object.isRequired, - // Material structure to be visualized - // eslint-disable-next-line react/forbid-prop-types - structure: PropTypes.object.isRequired, - // Expects "cell" property to represent the crystal unit cell for the atomic arrangement. Made.js UnitCell object. - // eslint-disable-next-line react/forbid-prop-types - cell: PropTypes.object.isRequired, - // eslint-disable-next-line react/forbid-prop-types - boundaryConditions: PropTypes.object.isRequired, - isConventionalCellShown: PropTypes.bool.isRequired, - isDrawBondsEnabled: PropTypes.bool.isRequired, - isViewAdjustable: PropTypes.bool.isRequired, -}; diff --git a/dist/components/chromeLayout.d.ts b/dist/components/chromeLayout.d.ts deleted file mode 100644 index 079c58b5..00000000 --- a/dist/components/chromeLayout.d.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Where the viewer's chrome sits, as numbers rather than as folklore. - * - * These four values describe one contract: which parts of the canvas are already claimed, so an - * overlay can avoid them. They were previously spread across the components that happened to need - * them - the inspector's width lived in SelectionInspector, and the mode pill imported it from - * there, coupling two sibling panels for one string and inviting an import cycle the first time the - * dependency ran the other way. - * - * Keeping them together is not tidiness for its own sake: the bug that produced them was the pill - * overlapping the inspector and both toolbars in an embedded panel, and that bug is exactly what - * happens when the numbers describing a shared layout live apart and drift. - */ -/** Selection inspector card width. The pill has to clear this while edit mode is on. */ -export declare const INSPECTOR_WIDTH = "19em"; -/** - * Inset clearing the chrome pinned to each edge. In pixels rather than `em` on purpose: the widths - * being cleared are themselves pixel constants - the icon strip is 44 px at a 12 px margin, the edit - * toolbar 52 px at 12 px - and an `em` here resolved against the caption font to 54 px, two pixels - * under the toolbar it was supposed to clear. - */ -export declare const SIDE_CHROME_INSET = "72px"; -/** - * Right inset while edit mode is on, clearing the selection inspector as well as the toolbar. An - * overlay's insets should describe the space that is genuinely free, so a "does it fit" decision can - * be a measurement of that space rather than a guess about what else is on screen. - */ -export declare const EDIT_SURFACE_INSET = "calc(72px + 19em + 8px)"; -/** - * Below this much free width, the mode pill keeps only its name and its exit. Set just above the - * width the full edit binding list occupies (~430 px measured), so the drop happens when the text - * would start fighting for space rather than after it already has. - */ -export declare const PILL_COMPACT_WIDTH_PX = 460; diff --git a/dist/components/chromeLayout.js b/dist/components/chromeLayout.js deleted file mode 100644 index 5d57a980..00000000 --- a/dist/components/chromeLayout.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Where the viewer's chrome sits, as numbers rather than as folklore. - * - * These four values describe one contract: which parts of the canvas are already claimed, so an - * overlay can avoid them. They were previously spread across the components that happened to need - * them - the inspector's width lived in SelectionInspector, and the mode pill imported it from - * there, coupling two sibling panels for one string and inviting an import cycle the first time the - * dependency ran the other way. - * - * Keeping them together is not tidiness for its own sake: the bug that produced them was the pill - * overlapping the inspector and both toolbars in an embedded panel, and that bug is exactly what - * happens when the numbers describing a shared layout live apart and drift. - */ -/** Selection inspector card width. The pill has to clear this while edit mode is on. */ -export const INSPECTOR_WIDTH = "19em"; -/** - * Inset clearing the chrome pinned to each edge. In pixels rather than `em` on purpose: the widths - * being cleared are themselves pixel constants - the icon strip is 44 px at a 12 px margin, the edit - * toolbar 52 px at 12 px - and an `em` here resolved against the caption font to 54 px, two pixels - * under the toolbar it was supposed to clear. - */ -export const SIDE_CHROME_INSET = "72px"; -/** - * Right inset while edit mode is on, clearing the selection inspector as well as the toolbar. An - * overlay's insets should describe the space that is genuinely free, so a "does it fit" decision can - * be a measurement of that space rather than a guess about what else is on screen. - */ -export const EDIT_SURFACE_INSET = `calc(${SIDE_CHROME_INSET} + ${INSPECTOR_WIDTH} + 8px)`; -/** - * Below this much free width, the mode pill keeps only its name and its exit. Set just above the - * width the full edit binding list occupies (~430 px measured), so the drop happens when the text - * would start fighting for space rather than after it already has. - */ -export const PILL_COMPACT_WIDTH_PX = 460; diff --git a/dist/enums.d.ts b/dist/enums.d.ts deleted file mode 100644 index 29019837..00000000 --- a/dist/enums.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -export declare const BOUNDARY_CONDITIONS: { - type: string; - name: string; - isNonPeriodic: boolean; -}[]; -export declare const ATOM_GROUP_NAME = "Atoms"; -export declare const ATOM_CONNECTIONS_GROUP_NAME = "Atom_Connections"; -export declare const ATOM_CONNECTION_LINE_NAME = "Atom_Connection"; -export declare const MIN_ANGLE_POINTS_DISTANCE = 0.7; -export declare const MEASUREMENT_LABELS_GROUP_NAME = "Measure_Labels"; -export declare const ANGLE = "ANGLE"; -export declare const LABELS_GROUP_NAME = "Labels_Group"; -export declare const ELEMENT_LABELS_GROUP_NAME = "Element_Labels_Group"; -export declare const COORDINATE_LABELS_GROUP_NAME = "Coordinate_Labels_Group"; -export declare const COLORS: { - RED: number; - GREEN: number; - ORANGE: number; - WHITE: number; - BLACK: number; -}; -export declare enum MEASUREMENT_MODES_ENUM { - NONE = "none", - DISTANCE = "distance", - ANGLE = "angle", - COORDINATE = "coordinate" -} -export declare const MEASUREMENT_MODES: { - NONE: string; - DISTANCE: string; - ANGLE: string; - COORDINATE: string; -}; -export declare const LABEL_TYPES: { - COORDINATE: string; - ELEMENT: string; - DISTANCE: string; - COORDINATE_MEASUREMENT: string; -}; diff --git a/dist/enums.js b/dist/enums.js deleted file mode 100644 index 3242ea76..00000000 --- a/dist/enums.js +++ /dev/null @@ -1,57 +0,0 @@ -export const BOUNDARY_CONDITIONS = [ - { - type: "pbc", - name: "Periodic Boundary Condition (pbc)", - isNonPeriodic: false, - }, - { - type: "bc1", - name: "Vacuum-Slab-Vacuum (bc1)", - isNonPeriodic: true, - }, - { - type: "bc2", - name: "Metal-Slab-Metal (bc2)", - isNonPeriodic: true, - }, - { - type: "bc3", - name: "Vacuum-Slab-Metal (bc3)", - isNonPeriodic: true, - }, -]; -export const ATOM_GROUP_NAME = "Atoms"; -export const ATOM_CONNECTIONS_GROUP_NAME = "Atom_Connections"; -export const ATOM_CONNECTION_LINE_NAME = "Atom_Connection"; -export const MIN_ANGLE_POINTS_DISTANCE = 0.7; -export const MEASUREMENT_LABELS_GROUP_NAME = "Measure_Labels"; -export const ANGLE = "ANGLE"; -export const LABELS_GROUP_NAME = "Labels_Group"; -export const ELEMENT_LABELS_GROUP_NAME = "Element_Labels_Group"; -export const COORDINATE_LABELS_GROUP_NAME = "Coordinate_Labels_Group"; -export const COLORS = { - RED: 0xff0000, - GREEN: 0x00ff00, - ORANGE: 0xffa500, - WHITE: 0xffffff, - BLACK: 0x000000, -}; -export var MEASUREMENT_MODES_ENUM; -(function (MEASUREMENT_MODES_ENUM) { - MEASUREMENT_MODES_ENUM["NONE"] = "none"; - MEASUREMENT_MODES_ENUM["DISTANCE"] = "distance"; - MEASUREMENT_MODES_ENUM["ANGLE"] = "angle"; - MEASUREMENT_MODES_ENUM["COORDINATE"] = "coordinate"; -})(MEASUREMENT_MODES_ENUM || (MEASUREMENT_MODES_ENUM = {})); -export const MEASUREMENT_MODES = { - NONE: "none", - DISTANCE: "distance", - ANGLE: "angle", - COORDINATE: "coordinate", -}; -export const LABEL_TYPES = { - COORDINATE: "coordinate", - ELEMENT: "element", - DISTANCE: "distance", - COORDINATE_MEASUREMENT: "coordinateMeasurement", -}; diff --git a/dist/exports.d.ts b/dist/exports.d.ts deleted file mode 100644 index d49dffb3..00000000 --- a/dist/exports.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ThreeDEditor } from "./components/ThreeDEditor"; -export { parseViewSettingsFromUrlParams, serializeViewSettingsToUrlParams } from "./utils/viewSettingsUrl"; diff --git a/dist/exports.js b/dist/exports.js deleted file mode 100644 index b58aa789..00000000 --- a/dist/exports.js +++ /dev/null @@ -1,2 +0,0 @@ -export { ThreeDEditor } from "./components/ThreeDEditor"; -export { parseViewSettingsFromUrlParams, serializeViewSettingsToUrlParams, } from "./utils/viewSettingsUrl"; diff --git a/dist/index.d.ts b/dist/index.d.ts deleted file mode 100644 index 531a4399..00000000 --- a/dist/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -export function renderThreeDEditor(materialConfig: any, newDomElement: any, options?: {}): void; diff --git a/dist/index.js b/dist/index.js deleted file mode 100644 index 0aece31b..00000000 --- a/dist/index.js +++ /dev/null @@ -1,35 +0,0 @@ -import { jsx as _jsx } from "react/jsx-runtime"; -import "./stylesheets/main.css"; -import "./MuiClassNameSetup"; -import JSONSchemasInterface from "@mat3ra/esse/dist/js/esse/JSONSchemasInterface"; -import allSchemas from "@mat3ra/esse/dist/js/schemas.json"; -import { Made } from "@mat3ra/made"; -import React from "react"; -import ReactDOM from "react-dom"; -import { ThreeDEditor } from "./components/ThreeDEditor"; -import { parseViewSettingsFromUrlParams } from "./utils/viewSettingsUrl"; -// Registering ESSE schemas is the host application's job, not the library's - so this lives in the -// standalone entry point, not in `ThreeDEditor` or anything reachable from `exports.js`. Consumers -// (materials-designer, web-app) register their own at startup. -// -// This file is the host for the standalone build, and without this it had no registry at all: since -// @mat3ra/made 2026.8.13-0 (#202) `Material.clone()` resolves `material-enhanced-hashed` through -// this interface, and `ThreeDEditor`'s constructor clones the material it is given - so the demo -// threw before React mounted anything. -JSONSchemasInterface.setSchemas(allSchemas); -// eslint-disable-next-line react/no-render-return-value -const renderThreeDEditor = (materialConfig, newDomElement, options = {}) => { - const config = materialConfig || Made.defaultMaterialConfig; - const domElement = newDomElement || document.getElementById("root"); - if (!domElement) { - console.warn("No root element found for rendering the 3D editor"); - return; - } - // Read view settings from URL query params unless explicitly provided - const initialViewSettings = options.initialViewSettings || - parseViewSettingsFromUrlParams(Object.fromEntries(new URLSearchParams(window.location.search))); - const currentMaterial = new Made.Material(config); - ReactDOM.render(_jsx(ThreeDEditor, { editable: true, isStandalone: true, material: currentMaterial, initialViewSettings: initialViewSettings }), domElement); -}; -window.renderThreeDEditor = renderThreeDEditor; -export { renderThreeDEditor }; diff --git a/dist/mixins/Hashmap.d.ts b/dist/mixins/Hashmap.d.ts deleted file mode 100644 index b084b4e2..00000000 --- a/dist/mixins/Hashmap.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as THREE from "three"; -export interface VerticesHashMap { - [key: string]: number[]; -} -export declare class VerticesHashMapHandler { - hashmap: VerticesHashMap; - constructor(); - add(key: string, value: number[]): void; - get(key: string): number[]; - iterateCoordinates(callback: (key: string, coordinateAsArray: number[]) => void): void; -} -export declare function createObjectVerticesHashMap(getVerticeKeyPerObject?: (object: THREE.Object3D) => string, objectsToUse?: THREE.Object3D[]): VerticesHashMapHandler; diff --git a/dist/mixins/Hashmap.js b/dist/mixins/Hashmap.js deleted file mode 100644 index d820516b..00000000 --- a/dist/mixins/Hashmap.js +++ /dev/null @@ -1,36 +0,0 @@ -import { getArrayFromVector } from "./utils_three"; -export class VerticesHashMapHandler { - constructor() { - this.hashmap = {}; - } - add(key, value) { - if (!this.hashmap[key]) { - this.hashmap[key] = [...value]; - } - else { - this.hashmap[key].push(...value); - } - } - get(key) { - return this.hashmap[key]; - } - iterateCoordinates(callback) { - Object.entries(this.hashmap).forEach(([key, vertices]) => { - for (let i = 0; i < vertices.length; i += 3) { - const coordinateAsArray = vertices.slice(i, i + 3); - callback(key, coordinateAsArray); - } - }); - } -} -export function createObjectVerticesHashMap(getVerticeKeyPerObject = (object) => object.name, objectsToUse = []) { - const positionsHashMap = new VerticesHashMapHandler(); - const getVerticeKeyPerObjectFn = getVerticeKeyPerObject; - if (objectsToUse) - objectsToUse.forEach((object) => { - const [x, y, z] = getArrayFromVector(object.position); - const mapKey = getVerticeKeyPerObjectFn(object); - positionsHashMap.add(mapKey, [x, y, z]); - }); - return positionsHashMap; -} diff --git a/dist/mixins/atoms.d.ts b/dist/mixins/atoms.d.ts deleted file mode 100644 index 5fed4e3a..00000000 --- a/dist/mixins/atoms.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { InMemoryEntity } from "@mat3ra/code/dist/js/entity"; -import { Basis, Material } from "@mat3ra/made"; -import * as THREE from "three"; -import { Object3D } from "three"; -export declare const AtomsMixin: (superclass: any) => { - new (config: any): { - [x: string]: any; - readonly structure: any; - setStructure(material: Material & InMemoryEntity): void; - readonly basis: any; - initSphereParameters(): void; - /** - * Prepares a sphere mesh object - * @param {String} color - * @param {Number} radius - * @param {Array} coordinate - * @return {THREE.Object3D} - */ - getSphereMeshObject({ color, radius, coordinate, }: { - color?: string; - radius?: number; - coordinate?: number[]; - }): any; - _getDefaultSettingsForElement(element?: any, scale?: any): { - color: any; - radius: number; - }; - createAtomsGroup(basis: Basis, atomRadiiScale: number): THREE.Group; - drawAtomsAsSpheres(atomRadiiScale: number): void; - getAtomColorByElement(element: string, pallette?: any): any; - getAtomRadiusByElement(element: string | number, scale?: number, radiimap?: any): number; - getAtomGroups(): THREE.Object3D[]; - isTHREEObjectAnAtom(object: any): object is THREE.Mesh; - getAtomNameFromObject(object: any): any; - getVerticeKeyPerAtom(atom: Object3D): any; - createAtomVerticesHashMap(getVerticeKeyPerAtom?: (atom: Object3D) => any, atoms?: THREE.Object3D[]): import("./Hashmap").VerticesHashMapHandler; - }; - [x: string]: any; -}; diff --git a/dist/mixins/atoms.js b/dist/mixins/atoms.js deleted file mode 100644 index 0bbec610..00000000 --- a/dist/mixins/atoms.js +++ /dev/null @@ -1,124 +0,0 @@ -import * as THREE from "three"; -import { ATOM_GROUP_NAME } from "../enums"; -import { createObjectVerticesHashMap } from "./Hashmap"; -import { ApplyGlow } from "./utils"; -/* - * Mixin containing the logic for dealing with atoms. - * Draws atoms as spheres and handles actions performed on them. - */ -export const AtomsMixin = (superclass) => class extends superclass { - constructor(config) { - super(config); - // to draw atoms as spheres - this.initSphereParameters(); - this.drawAtomsAsSpheres = this.drawAtomsAsSpheres.bind(this); - this.getAtomColorByElement = this.getAtomColorByElement.bind(this); - this.setStructure(this._structure); - } - get structure() { - return this._structure; - } - setStructure(material) { - this._structure = material.clone(); // clone original structure to assert that any updates are propagated to parents - this._basis = material.getBasis(); - this._basis.originalUnits = this._basis.units; - this._basis.toCartesian(); - this.verticesHashMap = this.createAtomVerticesHashMap(); - } - get basis() { - return this._basis; - } - initSphereParameters() { - // radius, segment, ring - const sphereGeometry = new THREE.SphereGeometry(1, this.settings.sphereQuality, this.settings.sphereQuality); - const sphereMaterial = new THREE.MeshLambertMaterial(); - this.sphereMesh = new THREE.Mesh(sphereGeometry, sphereMaterial); - } - /** - * Prepares a sphere mesh object - * @param {String} color - * @param {Number} radius - * @param {Array} coordinate - * @return {THREE.Object3D} - */ - getSphereMeshObject({ color = this.settings.defaultColor, radius = this.settings.sphereRadius, coordinate = [], }) { - // clone original mesh to optimize the speed - const sphereMesh = this.sphereMesh.clone(); - // set material color after cloning to optimize the speed and avoid re-creating material object - sphereMesh.material = sphereMesh.material.clone(); - sphereMesh.material.setValues({ color }); - // eslint-disable-next-line no-multi-assign - sphereMesh.scale.x = sphereMesh.scale.y = sphereMesh.scale.z = radius; - sphereMesh.position.set(...coordinate); - return sphereMesh; - } - _getDefaultSettingsForElement(element = this.settings.defaultElement, scale = this.settings.atomRadiiScale) { - return { - color: this.getAtomColorByElement(element), - radius: this.getAtomRadiusByElement(element, scale), - }; - } - createAtomsGroup(basis, atomRadiiScale) { - const atomsGroup = new THREE.Group(); - atomsGroup.name = ATOM_GROUP_NAME; - const { atomicLabelsArray, elementsWithLabelsArray } = basis; - basis.coordinates.forEach((atomicCoordinate, atomicIndex) => { - const element = basis.getElementByIndex(atomicIndex); - const coordinate = atomicCoordinate.value; - const sphereMesh = this.getSphereMeshObject({ - ...this._getDefaultSettingsForElement(element, atomRadiiScale), - coordinate, - }); - sphereMesh.name = `${element}-${atomicIndex}`; - // store any additional data in userData - // https://threejs.org/docs/#api/en/core/Object3D.userData - sphereMesh.userData = { - ...sphereMesh.userData, - symbolWithLabel: elementsWithLabelsArray[atomicIndex], - atomicIndex, - }; - const atomColor = this.getAtomColorByElement(element).toLowerCase(); - const label = parseInt(atomicLabelsArray[atomicIndex], 10) || 0; - // set glow according to the label value as offset, currently - // only single digit numeric labels are allowed, in practice we - // expect only two different labels: 1 and 2 for up and down - // spin representations - ApplyGlow(sphereMesh, atomColor, label); - atomsGroup.add(sphereMesh); - }); - return atomsGroup; - } - drawAtomsAsSpheres(atomRadiiScale) { - const basis = this.areNonPeriodicBoundariesPresent - ? this.basisWithElementsInsideNonPeriodicBoundaries - : this.basis; - this.repeatAtomsAtRepetitionCoordinates(this.createAtomsGroup(basis, atomRadiiScale)); - } - getAtomColorByElement(element, pallette = this.settings.elementColors) { - return pallette[element] || this.settings.defaultColor; - } - getAtomRadiusByElement(element, scale = 1.0, radiimap = this.settings.vdwRadii) { - return (radiimap[element] || this.settings.sphereRadius) * scale; - } - getAtomGroups() { - const atomGroups = []; - this.structureGroup.children.forEach((group) => { - if (group.name === ATOM_GROUP_NAME) { - atomGroups.push(...group.children); - } - }); - return atomGroups; - } - isTHREEObjectAnAtom(object) { - return object instanceof THREE.Mesh; - } - getAtomNameFromObject(object) { - return object.name.split("-")[0]; - } - getVerticeKeyPerAtom(atom) { - return this.getAtomNameFromObject(atom); - } - createAtomVerticesHashMap(getVerticeKeyPerAtom = this.getVerticeKeyPerAtom.bind(this), atoms = this.getAtomGroups()) { - return createObjectVerticesHashMap(getVerticeKeyPerAtom, atoms); - } -}; diff --git a/dist/mixins/base.d.ts b/dist/mixins/base.d.ts deleted file mode 100644 index 4e8f0e14..00000000 --- a/dist/mixins/base.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as THREE from "three"; -export declare class BaseTHREEGroupManager { - THREEGroup: THREE.Group; - isVisible: boolean; - config: any; - waveStructureGroup: THREE.Group; - waveCamera: THREE.Camera; - wave: any; - constructor(waveStructureGroup: THREE.Group, waveCamera: THREE.Camera, wave: any, groupName?: string); - addToWaveStructureGroup(): void; - toggleVisibility(): void; -} diff --git a/dist/mixins/base.js b/dist/mixins/base.js deleted file mode 100644 index ca0fa1fb..00000000 --- a/dist/mixins/base.js +++ /dev/null @@ -1,19 +0,0 @@ -import * as THREE from "three"; -export class BaseTHREEGroupManager { - constructor(waveStructureGroup, waveCamera, wave, groupName = "base-group-name") { - this.isVisible = false; - this.THREEGroup = new THREE.Group(); - this.THREEGroup.name = groupName; - this.THREEGroup.visible = this.isVisible; - this.waveStructureGroup = waveStructureGroup; - this.waveCamera = waveCamera; - this.wave = wave; - } - addToWaveStructureGroup() { - this.waveStructureGroup.add(this.THREEGroup); - } - toggleVisibility() { - this.isVisible = !this.isVisible; - this.THREEGroup.visible = this.isVisible; - } -} diff --git a/dist/mixins/bonds.d.ts b/dist/mixins/bonds.d.ts deleted file mode 100644 index 5a42d5eb..00000000 --- a/dist/mixins/bonds.d.ts +++ /dev/null @@ -1,75 +0,0 @@ -import * as THREE from "three"; -interface BondDataInterface { - length: { - value: number; - }; -} -type ElementAndCoordinateAsArray = [string, number[]]; -export declare const BondsMixin: (superclass: any) => { - new (config: any): { - [x: string]: any; - /** - * Creates bond asynchronously as bonds creation takes time for large structures. - */ - createBondsAsync(): void; - /** - * Whether to draw the bond between given elements. - * The elements are considered bonded if their distance <= bond length * connectivity factor - * @param element1 {String} symbol of the first element - * @param coordinate1 {Array} coordinates of the first element - * @param element2 {String} symbol of the second element - * @param coordinate2 {Array} coordinates of the second element - * @param bondsData {Array} an array of bond data entries for unique element pairs inside structure. - * @returns {Boolean} - */ - areElementsBonded(element1: string, coordinate1: number[], element2: string, coordinate2: number[], bondsData: BondDataInterface[]): boolean; - /** - * Returns bonds data for unique element pairs. This is to avoid calling getElementsBondsData for all elements - * combinations as it is required to repeat the cell in all directions to determine the bonds. - * @returns {Array} an array of bond data entries for unique element pairs inside structure. - */ - getBondsDataForUniqueElementPairs(): BondDataInterface[]; - /** - * Returns the maximum bond length in the structure. - * @param bondsData {Array} an array of bond data entries for unique element pairs inside structure. - * @returns {Number} - */ - getMaxBondLength(bondsData: BondDataInterface[]): number; - /** - * Returns an array of [element, coordinate] for all elements and their neighbors. - * The basis is repeated in all directions to find whether the elements at the edges have bonds to neighbors cells - * elements. Only elements with distance to edge less or equal than the maximum bond length are repeated as the other - * elements can not have bond with the elements in repeated cells. - * @param maxBondLength {Number} - * @return {Array} - */ - getElementsAndCoordinatesArrayWithEdgeNeighbors(maxBondLength: number): ElementAndCoordinateAsArray[]; - /** - * Create the instanced mesh for all bonds, including repetitions. - * k-d tree algorithm is used to optimize the time to find the element's neighbors. - * See https://en.wikipedia.org/wiki/K-d_tree for more information. - */ - createBondsGroup(): THREE.InstancedMesh | THREE.Group; - /** - * Creates an InstancedMesh containing all bonds, accounting for repetitions. - */ - createInstancedMeshForBonds(baseBondsData: any[]): THREE.InstancedMesh | THREE.Group; - /** - * Draw bonds. Bonds are created synchronously if the asynchronous callback (createBondsAsync) to draw bonds - * in background has not returned yet. This may happen if the structure is large and draw bonds is toggled quickly. - * We need this to block the UI until the bonds are drawn. - */ - drawBonds(): void; - /** - * Returns bond data properties (position, quaternion, height, color). - */ - getBondData(element1: string, index1: number, coordinate1: number[], element2: string, index2: number, coordinate2: number[]): { - position: THREE.Vector3; - quaternion: THREE.Quaternion; - height: number; - color: any; - }; - }; - [x: string]: any; -}; -export {}; diff --git a/dist/mixins/bonds.js b/dist/mixins/bonds.js deleted file mode 100644 index c4c32530..00000000 --- a/dist/mixins/bonds.js +++ /dev/null @@ -1,215 +0,0 @@ -import { filterBondsDataByElementsAndOrder, getElementsBondsData } from "@mat3ra/periodic-table"; -import { sharedUtils } from "@mat3ra/utils"; -import createKDTree from "static-kdtree"; -import * as THREE from "three"; -/* - * Mixin containing the logic for dealing with bonds. - */ -export const BondsMixin = (superclass) => class extends superclass { - constructor(config) { - super(config); - this.createBondsAsync(); - this.isDrawBondsEnabled = false; - this.drawBonds = this.drawBonds.bind(this); - this.createBondsAsync = this.createBondsAsync.bind(this); - } - /** - * Creates bond asynchronously as bonds creation takes time for large structures. - */ - createBondsAsync() { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const clsInstance = this; - clsInstance.areBondsCreated = false; - setTimeout(() => { - clsInstance.bondsGroup = clsInstance.createBondsGroup(); - clsInstance.areBondsCreated = true; - }, 10); - } - /** - * Whether to draw the bond between given elements. - * The elements are considered bonded if their distance <= bond length * connectivity factor - * @param element1 {String} symbol of the first element - * @param coordinate1 {Array} coordinates of the first element - * @param element2 {String} symbol of the second element - * @param coordinate2 {Array} coordinates of the second element - * @param bondsData {Array} an array of bond data entries for unique element pairs inside structure. - * @returns {Boolean} - */ - // TODO: move to made basis bonded - areElementsBonded(element1, coordinate1, element2, coordinate2, bondsData) { - const distance = sharedUtils.math.vDist(coordinate1, coordinate2); - const connectivityFactor = this.settings.chemicalConnectivityFactor; - return Boolean(filterBondsDataByElementsAndOrder(bondsData, element1, element2).find((b) => { - return (b.length.value && - distance !== undefined && - distance <= b.length.value * connectivityFactor); - })); - } - /** - * Returns bonds data for unique element pairs. This is to avoid calling getElementsBondsData for all elements - * combinations as it is required to repeat the cell in all directions to determine the bonds. - * @returns {Array} an array of bond data entries for unique element pairs inside structure. - */ - // TODO: move to made basis bonded - getBondsDataForUniqueElementPairs() { - const bonds = []; - const { uniqueElements } = this.basis; - uniqueElements.forEach((element1, index1) => { - uniqueElements.forEach((element2, index2) => { - if (element1 && element2 && index2 >= index1) { - Array.prototype.push.apply(bonds, getElementsBondsData(element1, element2)); - } - }); - }); - return bonds; - } - /** - * Returns the maximum bond length in the structure. - * @param bondsData {Array} an array of bond data entries for unique element pairs inside structure. - * @returns {Number} - */ - // TODO: move to made basis bonded - getMaxBondLength(bondsData) { - const connectivityFactor = this.settings.chemicalConnectivityFactor; - return (connectivityFactor * - sharedUtils.math.max(bondsData.map((b) => b.length.value || 0))); - } - /** - * Returns an array of [element, coordinate] for all elements and their neighbors. - * The basis is repeated in all directions to find whether the elements at the edges have bonds to neighbors cells - * elements. Only elements with distance to edge less or equal than the maximum bond length are repeated as the other - * elements can not have bond with the elements in repeated cells. - * @param maxBondLength {Number} - * @return {Array} - */ - // TODO: move to made basis bonded - getElementsAndCoordinatesArrayWithEdgeNeighbors(maxBondLength) { - const elementsAndCoordinatesArray1 = this.basis.elementsAndCoordinatesArray; - const planes = this.getCellPlanes(this.cell); - const { cell } = this; - const vecA = new THREE.Vector3(cell.ax, cell.ay, cell.az); - const vecB = new THREE.Vector3(cell.bx, cell.by, cell.bz); - const vecC = new THREE.Vector3(cell.cx, cell.cy, cell.cz); - const result = [...elementsAndCoordinatesArray1]; - elementsAndCoordinatesArray1.forEach(([element, coord]) => { - const cartesianCoord = new THREE.Vector3(...coord); - let nearEdge = false; - for (let i = 0; i < planes.length; i++) { - if (Math.abs(planes[i].distanceToPoint(cartesianCoord)) <= maxBondLength) { - nearEdge = true; - break; - } - } - if (nearEdge) { - [-1, 0, 1].forEach((shiftI) => { - [-1, 0, 1].forEach((shiftJ) => { - [-1, 0, 1].forEach((shiftK) => { - if (shiftI === 0 && shiftJ === 0 && shiftK === 0) - return; - const shiftedCoord = cartesianCoord - .clone() - .addScaledVector(vecA, shiftI) - .addScaledVector(vecB, shiftJ) - .addScaledVector(vecC, shiftK); - result.push([ - element, - [shiftedCoord.x, shiftedCoord.y, shiftedCoord.z], - ]); - }); - }); - }); - } - }); - return result; - } - /** - * Create the instanced mesh for all bonds, including repetitions. - * k-d tree algorithm is used to optimize the time to find the element's neighbors. - * See https://en.wikipedia.org/wiki/K-d_tree for more information. - */ - createBondsGroup() { - const bondsData = this.getBondsDataForUniqueElementPairs(); - const maxBondLength = this.getMaxBondLength(bondsData); - const elementsAndCoordinatesArray1 = this.basis.elementsAndCoordinatesArray; - const elementsAndCoordinatesArray2 = this.getElementsAndCoordinatesArrayWithEdgeNeighbors(maxBondLength); - const tree = createKDTree(elementsAndCoordinatesArray2.map(([element, coordinate]) => coordinate)); - const baseBondsData = []; - elementsAndCoordinatesArray1.forEach(([element1, coordinate1], index1) => { - // iterate over all elements in maxBondLength radius of this element. O(3n^(2/3)) - tree.rnn(coordinate1, maxBondLength, (index2) => { - const [element2, coordinate2] = elementsAndCoordinatesArray2[index2]; - if (index2 === index1 || - !this.areElementsBonded(element1, coordinate1, element2, coordinate2, bondsData)) - return; - const bondData = this.getBondData(element1, index1, coordinate1, element2, index2, coordinate2); - baseBondsData.push(bondData); - }); - }); - return this.createInstancedMeshForBonds(baseBondsData); - } - /** - * Creates an InstancedMesh containing all bonds, accounting for repetitions. - */ - createInstancedMeshForBonds(baseBondsData) { - const { coordinates: repetitionCoords } = this.getRepetitionInfo(); - const totalRepetitions = 1 + repetitionCoords.length; - const totalInstances = baseBondsData.length * totalRepetitions; - if (totalInstances === 0) { - return new THREE.Group(); - } - const geometry = new THREE.CylinderGeometry(0.1, 0.1, 1, 8, 1); - geometry.translate(0, 0.5, 0); // shift so scaling operates from the base - const material = new THREE.MeshBasicMaterial(); - const instancedMesh = new THREE.InstancedMesh(geometry, material, totalInstances); - const matrix = new THREE.Matrix4(); - const colorObj = new THREE.Color(); - let instanceIndex = 0; - const allShifts = [[0, 0, 0], ...repetitionCoords]; - allShifts.forEach((shiftArr) => { - const shiftVec = new THREE.Vector3(...shiftArr); - baseBondsData.forEach((bond) => { - const finalPos = bond.position.clone().add(shiftVec); - matrix.compose(finalPos, bond.quaternion, new THREE.Vector3(1, bond.height, 1)); - instancedMesh.setMatrixAt(instanceIndex, matrix); - instancedMesh.setColorAt(instanceIndex, colorObj.set(bond.color)); - instanceIndex += 1; - }); - }); - instancedMesh.instanceMatrix.needsUpdate = true; - if (instancedMesh.instanceColor) - instancedMesh.instanceColor.needsUpdate = true; - return instancedMesh; - } - /** - * Draw bonds. Bonds are created synchronously if the asynchronous callback (createBondsAsync) to draw bonds - * in background has not returned yet. This may happen if the structure is large and draw bonds is toggled quickly. - * We need this to block the UI until the bonds are drawn. - */ - drawBonds() { - this.createBondsAsync(); - if (!this.areBondsCreated) { - this.bondsGroup = this.createBondsGroup(); - this.areBondsCreated = true; - } - this.structureGroup.add(this.bondsGroup); - } - /** - * Returns bond data properties (position, quaternion, height, color). - */ - getBondData(element1, index1, coordinate1, element2, index2, coordinate2) { - const vector1 = new THREE.Vector3(...coordinate1); - const vector2 = new THREE.Vector3(...coordinate2); - const direction = new THREE.Vector3().subVectors(vector2, vector1); - const height = direction.length() / 2; - direction.normalize(); - // create quaternion to rotate the cylinder - const quaternion = new THREE.Quaternion(); - quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction); - return { - position: vector1, - quaternion, - height, - color: this.getAtomColorByElement(element1), - }; - } -}; diff --git a/dist/mixins/boundary.d.ts b/dist/mixins/boundary.d.ts deleted file mode 100644 index c3fca07e..00000000 --- a/dist/mixins/boundary.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -export function BoundaryMixin(superclass: any): { - new (config: any): { - [x: string]: any; - boundaryConditions: any; - /** - * Boundaries are drawn only if type is "bc1", "bc2", or "bc3". - * bc1 : Immerse the slab between two semi-infinite vacuum regions; - * bc2 : Immerse the slab between two semi-infinite metallic electrodes, with optional fixed field applied between them. - * bc3 : Immerse the slab between one semi-infinite vacuum region (left) and one semi-infinite metallic electrode (right). - */ - readonly areNonPeriodicBoundariesPresent: boolean; - /** - * Returns a plane-like mesh object with given corner vertices in counterclockwise order. - * @param color {Number} mesh object color. - * @param coordinates1 {Array} first point. - * @param coordinates2 {Array} second point. - * @param coordinates3 {Array} third point. - * @param coordinates4 {Array} fourth point. - * @param zOffset {Number} offset to add to the z coordinate of points forming the object. - */ - getBoundaryMeshObject(color: number, coordinates1: any[], coordinates2: any[], coordinates3: any[], coordinates4: any[], zOffset?: number): THREE.Mesh; - /** - * Returns the z offset to add to the boundary planes. Note that the c axis of the cell and z axis of coordinate system - * are always aligned by convention, hence this.cVectorLength / 2. - */ - readonly boundaryMeshObjectZOffset: any; - /** - * Draw boundaries with +/- [L_z/2 + this.boundaryConditions.offset] z coordinates. - */ - drawBoundaries(): void; - /** - * Returns a basis with elements inside boundary conditions. - */ - readonly basisWithElementsInsideNonPeriodicBoundaries: any; - }; - [x: string]: any; -}; -import * as THREE from "three"; diff --git a/dist/mixins/boundary.js b/dist/mixins/boundary.js deleted file mode 100644 index 8b9ccde8..00000000 --- a/dist/mixins/boundary.js +++ /dev/null @@ -1,97 +0,0 @@ -import * as THREE from "three"; -import { BOUNDARY_CONDITIONS } from "../enums"; -export const BoundaryMixin = (superclass) => class extends superclass { - /** - * boundaryConditions.type {String}: type of the boundary. - * boundaryConditions.offset {Number} boundary offset (esm_w). - */ - constructor(config) { - super(config); - this.boundaryConditions = config.boundaryConditions || {}; - } - /** - * Boundaries are drawn only if type is "bc1", "bc2", or "bc3". - * bc1 : Immerse the slab between two semi-infinite vacuum regions; - * bc2 : Immerse the slab between two semi-infinite metallic electrodes, with optional fixed field applied between them. - * bc3 : Immerse the slab between one semi-infinite vacuum region (left) and one semi-infinite metallic electrode (right). - */ - get areNonPeriodicBoundariesPresent() { - return BOUNDARY_CONDITIONS.filter((e) => e.isNonPeriodic) - .map((e) => e.type) - .includes(this.boundaryConditions.type); - } - /** - * Returns a plane-like mesh object with given corner vertices in counterclockwise order. - * @param color {Number} mesh object color. - * @param coordinates1 {Array} first point. - * @param coordinates2 {Array} second point. - * @param coordinates3 {Array} third point. - * @param coordinates4 {Array} fourth point. - * @param zOffset {Number} offset to add to the z coordinate of points forming the object. - */ - getBoundaryMeshObject(color, coordinates1, coordinates2, coordinates3, coordinates4, zOffset = 0) { - const geometry = new THREE.BufferGeometry(); - const vertices = new Float32Array([coordinates1, coordinates2, coordinates3, coordinates3, coordinates4, coordinates1] - .map(([x, y, z]) => [x, y, z + zOffset]) - .flat()); - geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3)); - const material = new THREE.MeshBasicMaterial({ - color, - opacity: 0.5, - transparent: true, - side: THREE.DoubleSide, - }); - return new THREE.Mesh(geometry, material); - } - /** - * Returns the z offset to add to the boundary planes. Note that the c axis of the cell and z axis of coordinate system - * are always aligned by convention, hence this.cVectorLength / 2. - */ - get boundaryMeshObjectZOffset() { - return this.boundaryConditions.offset + this.cVectorLength / 2; - } - /** - * Draw boundaries with +/- [L_z/2 + this.boundaryConditions.offset] z coordinates. - */ - drawBoundaries() { - if (this.areNonPeriodicBoundariesPresent) { - const vertices = this.getCellVertices(this.cell); - const colors = this.settings.boundaryConditionTypeColors[this.boundaryConditions.type]; - const boundaryMeshObjectVertices = [ - vertices[0], - vertices[1], - vertices[3], - vertices[2], - ]; - const plane1 = this.getBoundaryMeshObject(colors[0], ...boundaryMeshObjectVertices, this.boundaryMeshObjectZOffset); - const plane2 = this.getBoundaryMeshObject(colors[1], ...boundaryMeshObjectVertices, -this.boundaryMeshObjectZOffset); - this.repeatObject3DAtRepetitionCoordinates(plane1); - this.repeatObject3DAtRepetitionCoordinates(plane2); - } - } - /** - * Returns a basis with elements inside boundary conditions. - */ - get basisWithElementsInsideNonPeriodicBoundaries() { - const newBasis = this.basis.clone(); - newBasis.elements = []; - newBasis.coordinates = []; - const basisCloneInCrystalCoordinates = this.basis.clone(); - newBasis.toCrystal(); - basisCloneInCrystalCoordinates.toCrystal(); - basisCloneInCrystalCoordinates.elements.forEach((element, index) => { - const coord = basisCloneInCrystalCoordinates.getCoordinateValueByIndex(index); - newBasis.addAtom({ - element, - coordinate: [ - coord[0], - coord[1], - Math.abs(coord[2]) <= 0.5 ? coord[2] : coord[2] - 1, - ], - }); - }); - if (this.basis.isInCartesianUnits) - newBasis.toCartesian(); - return newBasis; - } -}; diff --git a/dist/mixins/cell.d.ts b/dist/mixins/cell.d.ts deleted file mode 100644 index 40a86b0a..00000000 --- a/dist/mixins/cell.d.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { UnitCell } from "@mat3ra/made"; -import * as THREE from "three"; -/** - * Return type for getCellViewParams method - */ -interface CellViewParams { - center: number[]; - width: number; - height: number; - maxSize: number; -} -export declare const CellMixin: (superclass: any) => { - new (config: any): { - [x: string]: any; - cell: UnitCell; - setCell(s: UnitCell): void; - /** - * Returns an array of vertices in 3D space forming the cell. - * @param cell {Object} unitCell class instance. - * @param zMultiplier {Number} specifies a multiplier to adjust the z coordinates of the cell vertices with. - */ - getCellVertices(cell: UnitCell, zMultiplier?: number): number[][]; - /** - * Returns the cell's center point in 3D space in the form of coordinate array, - * as well as cell height, width, and the maximum between the height and width. - * @param cell {Object} unitCell class instance. - * @returns {{center:Array, width:Number, height:Number, maxSize:Number}} - */ - getCellViewParams(cell?: UnitCell): CellViewParams; - /** - * Returns a LineSegments object representing the cell with given edges. - * @param cell {Object} unitCell class instance. - * @param edges {Array} an array of vertex indices used to form the line segments. - * @param zMultiplier {Number} specifies a multiplier to adjust the z coordinates of the cell vertices with. - * @param lineColor {Number} line segment color - * @returns {LineSegments} - */ - getUnitCellObjectByEdges(cell: UnitCell, edges: number[], zMultiplier?: number, lineColor?: any): THREE.LineSegments; - /** - * Returns a LineSegments object representing the full unitCell (with all edges). - */ - getUnitCellObject(cell: UnitCell): THREE.LineSegments; - /** - * Draw unitCell in canvas. 2 half up/down cells (without top edges) are drawn if boundary conditions are present. - */ - drawUnitCell(cell?: UnitCell): void; - /** - * Returns an array of THREE.Plane corresponding to the cell's faces. - */ - getCellPlanes(cell: UnitCell): THREE.Plane[]; - /** - * Return the length of unitCell c vector. - */ - readonly cVectorLength: number; - }; - [x: string]: any; -}; -export {}; diff --git a/dist/mixins/cell.js b/dist/mixins/cell.js deleted file mode 100644 index e4246cc4..00000000 --- a/dist/mixins/cell.js +++ /dev/null @@ -1,142 +0,0 @@ -import * as THREE from "three"; -const TV3 = THREE.Vector3; -/* - * Mixin containing the logic for dealing with the calculation/unit cell. - * Draws cell edges as lines. - * NOTE: `this._cell` is set inside WaveBase.constructor. - */ -export const CellMixin = (superclass) => class extends superclass { - constructor(config) { - super(config); - this._cell = config.cell; - this.drawUnitCell = this.drawUnitCell.bind(this); - } - get cell() { - return this._cell; - } - set cell(s) { - this._cell = s; - } - setCell(s) { - this.cell = s; - } - /** - * Returns an array of vertices in 3D space forming the cell. - * @param cell {Object} unitCell class instance. - * @param zMultiplier {Number} specifies a multiplier to adjust the z coordinates of the cell vertices with. - */ - // TODO: move to made.unit_cell - getCellVertices(cell, zMultiplier = 1) { - return [ - [0, 0, 0], - [cell.ax, cell.ay, cell.az], - [cell.bx, cell.by, cell.bz], - [cell.ax + cell.bx, cell.ay + cell.by, cell.az + cell.bz], - [cell.cx, cell.cy, cell.cz * zMultiplier], - [cell.cx + cell.ax, cell.cy + cell.ay, cell.cz * zMultiplier + cell.az], - [cell.cx + cell.bx, cell.cy + cell.by, cell.cz * zMultiplier + cell.bz], - [ - cell.cx + cell.ax + cell.bx, - cell.cy + cell.ay + cell.by, - cell.cz * zMultiplier + cell.az + cell.bz, - ], - ]; - } - /** - * Returns the cell's center point in 3D space in the form of coordinate array, - * as well as cell height, width, and the maximum between the height and width. - * @param cell {Object} unitCell class instance. - * @returns {{center:Array, width:Number, height:Number, maxSize:Number}} - */ - getCellViewParams(cell = this._cell) { - let diagonal; - if (this.areNonPeriodicBoundariesPresent) { - const verticesUp = this.getCellVertices(cell, 0.5); - const verticesDown = this.getCellVertices(cell, -0.5); - diagonal = [verticesUp[4], verticesDown[7]]; - } - else { - const vertices = this.getCellVertices(cell); - diagonal = [vertices[0], vertices[7]]; - } - const center = [ - (diagonal[0][0] + diagonal[1][0]) / 2, - (diagonal[0][1] + diagonal[1][1]) / 2, - (diagonal[0][2] + diagonal[1][2]) / 2, - ]; - const width = Math.abs(diagonal[0][1] + diagonal[1][1]); - const height = Math.abs(diagonal[0][2] + diagonal[1][2]); - const maxSize = Math.max(width, height); - return { - center, - width, - height, - maxSize, - }; - } - /** - * Returns a LineSegments object representing the cell with given edges. - * @param cell {Object} unitCell class instance. - * @param edges {Array} an array of vertex indices used to form the line segments. - * @param zMultiplier {Number} specifies a multiplier to adjust the z coordinates of the cell vertices with. - * @param lineColor {Number} line segment color - * @returns {LineSegments} - */ - getUnitCellObjectByEdges(cell, edges, zMultiplier = 1, lineColor = this.settings.defaultColor) { - const vertices = this.getCellVertices(cell, zMultiplier); - const points = edges.map((edge) => new TV3(vertices[edge][0], vertices[edge][1], vertices[edge][2])); - const geometry = new THREE.BufferGeometry().setFromPoints(points); - const lineMaterial = new THREE.LineBasicMaterial({ - color: lineColor, - linewidth: this.settings.lineWidth, - }); - return new THREE.LineSegments(geometry, lineMaterial); - } - /** - * Returns a LineSegments object representing the full unitCell (with all edges). - */ - getUnitCellObject(cell) { - const edges = [0, 1, 0, 2, 1, 3, 2, 3, 4, 5, 4, 6, 5, 7, 6, 7, 0, 4, 1, 5, 2, 6, 3, 7]; - this.unitCellObject = this.getUnitCellObjectByEdges(cell, edges); - this.unitCellObject.name = "Cell"; - return this.unitCellObject; - } - /** - * Draw unitCell in canvas. 2 half up/down cells (without top edges) are drawn if boundary conditions are present. - */ - drawUnitCell(cell = this.cell) { - if (this.areNonPeriodicBoundariesPresent) { - const edges = [0, 1, 0, 2, 1, 3, 2, 3, 0, 4, 1, 5, 2, 6, 3, 7]; - const cellObjectUp = this.getUnitCellObjectByEdges(cell, edges, 0.5); - const cellObjectDown = this.getUnitCellObjectByEdges(cell, edges, -0.5, this.settings.colors.gray); - this.structureGroup.add(cellObjectDown); - this.structureGroup.add(cellObjectUp); - } - else { - const unitCellObject = this.getUnitCellObject(cell); - this.structureGroup.add(unitCellObject); - } - } - /** - * Returns an array of THREE.Plane corresponding to the cell's faces. - */ - getCellPlanes(cell) { - const vertices = this.getCellVertices(cell).map((a) => new THREE.Vector3(...a)); - return [ - [0, 1, 2], - [0, 1, 4], - [1, 3, 5], - [3, 2, 7], - [0, 2, 4], - [4, 6, 5], - ].map((face) => { - return new THREE.Plane().setFromCoplanarPoints(vertices[face[0]], vertices[face[1]], vertices[face[2]]); - }); - } - /** - * Return the length of unitCell c vector. - */ - get cVectorLength() { - return new THREE.Vector3(this.cell.cx, this.cell.cy, this.cell.cz).length(); - } -}; diff --git a/dist/mixins/controls.d.ts b/dist/mixins/controls.d.ts deleted file mode 100644 index b133b513..00000000 --- a/dist/mixins/controls.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function ControlsMixin(superclass: any): { - new (): { - [x: string]: any; - toggleBoolean(name: any, antagonistNames?: any[]): void; - areTwoObjectsShallowEqual(o1: any, o2: any): boolean; - getTwoObjectsShallowDifferentKeys(o1: any, o2: any): {}; - }; - [x: string]: any; -}; diff --git a/dist/mixins/controls.js b/dist/mixins/controls.js deleted file mode 100644 index 2a0fc75a..00000000 --- a/dist/mixins/controls.js +++ /dev/null @@ -1,310 +0,0 @@ -/* eslint-disable max-classes-per-file */ -import * as THREE from "three"; -import { OrbitControls } from "three/examples/jsm/controls/OrbitControls"; -import { UtilsMixin } from "./utils"; -const TV3 = THREE.Vector3; -/* - * Mixin containing the logic for dealing with orbit controls for THREE scene. - * Example: https://threejs.org/examples/misc_controls_orbit.html - */ -const OrbitControlsMixin = (superclass) => class extends superclass { - constructor(config) { - super(config); - this.initOrbitControls(false); - // Bind methods to context - this.initOrbitControls = this.initOrbitControls.bind(this); - this.enableOrbitControls = this.enableOrbitControls.bind(this); - this.disableOrbitControls = this.disableOrbitControls.bind(this); - this.enableOrbitControlsAnimation = this.enableOrbitControlsAnimation.bind(this); - this.disableOrbitControlsAnimation = this.disableOrbitControlsAnimation.bind(this); - this.initSecondAxes = this.initSecondAxes.bind(this); - this.updateSecondAxes = this.updateSecondAxes.bind(this); - // Bind methods to context to avoid losing `this` reference in requestAnimationFrame - this.performOrbitControlsAnimation = this.performOrbitControlsAnimation.bind(this); - } - initOrbitControls(enabled = false) { - this.initSecondAxes(); - this.orbitControls = new OrbitControls(this.camera, this.renderer.domElement); - this.orbitControls.enabled = enabled; - this.orbitControls.enableZoom = true; - this.orbitControls.enableKeys = false; - // TODO: use a settings variable instead of explicit number below - this.orbitControls.rotateSpeed = 2.0; - this.orbitControls.zoomSpeed = 2.0; - this.orbitControls.update(); - } - adjustOrbitControlsTarget(newTarget) { - this.orbitControls.target.copy(new TV3(...newTarget)); - } - disableOrbitControls() { - if (!this.orbitControls) - return; - this.orbitControls.enabled = false; - this.hideSecondAxes(); - this.orbitControls.removeEventListener("change", this.updateSecondAxesBound, false); - this.setCursorStyle(); - } - enableOrbitControls() { - this.orbitControls.enabled = true; - this.showSecondAxes(); - this.updateSecondAxes(); // align second camera wrt the first one and thus make it visible - this.updateSecondAxesBound = (e) => this.updateSecondAxes(e); - this.orbitControls.addEventListener("change", this.updateSecondAxesBound, false); - this.setCursorStyle("alias"); - } - /** - * Getter returning the status of rotating animation for orbit controls. - * @return {Boolean} - */ - get isOrbitControlsAnimationEnabled() { - return Boolean(this.animationFrameId); - } - /** - * Enable automatic rotation of the camera around the current focus point. - * Implemented through `window.requestAnimationFrame`. - */ - enableOrbitControlsAnimation() { - if (!this.orbitControls) - return; - this.orbitControls.autoRotate = true; - this.performOrbitControlsAnimation(); - } - performOrbitControlsAnimation(action = () => { }) { - this.animationFrameId = window.requestAnimationFrame(this.performOrbitControlsAnimation); - // required if controls.enableDamping or controls.autoRotate are set to true - this.orbitControls.update(); - this.render(); - if (typeof action === "function") { - action(); - } - } - disableOrbitControlsAnimation() { - if (!this.orbitControls) - return; - this.orbitControls.autoRotate = false; - window.cancelAnimationFrame(this.animationFrameId); - this.animationFrameId = null; - } - toggleOrbitControlsAnimation() { - if (this.animationFrameId) { - this.disableOrbitControlsAnimation(); - } - else { - this.enableOrbitControlsAnimation(); - } - } - /* - * AXES-RELATED FUNCTIONALITY. - * TODO: separate to its own mixin - */ - /** - * Initialize Axes Helper - XYZ axes with a mesh plane in XY. - */ - initAxes() { - // length of the axes - const length = 100; - const lineMaterial = new THREE.LineDashedMaterial({ - ...this.settings.lineMaterial, - color: this.settings.colors.amber, - }); - const points = [ - new THREE.Vector3(-length / 2, 0, 0), - new THREE.Vector3(length / 2, 0, 0), - new THREE.Vector3(0, -length / 2, 0), - new THREE.Vector3(0, length / 2, 0), - new THREE.Vector3(0, 0, 0), - new THREE.Vector3(0, 0, length / 2), - ]; - const geometry = new THREE.BufferGeometry().setFromPoints(points); - const line = new THREE.LineSegments(geometry, lineMaterial); - line.computeLineDistances(); - // group axes vertices in the viewer together and treat as a 3D object - this.axesGroup = new THREE.Object3D(); - const gridHelper = new THREE.GridHelper(100, 100, this.settings.colors.amber, this.settings.colors.gray); - gridHelper.geometry.rotateX(Math.PI / 2); - gridHelper.position.x = 0; - gridHelper.position.y = 0; - this.axesGroup.add(line, gridHelper); - this.scene.add(this.axesGroup); - } - deleteAxes() { - if (!this.axesGroup) - return; - this.scene.remove(this.axesGroup); - delete this.axesGroup; - } - get areAxesEnabled() { - return Boolean(this.axesGroup); - } - toggleAxes() { - if (this.areAxesEnabled) { - this.deleteAxes(); - } - else { - this.initAxes(); - } - this.render(); - } - /* - * Initialize a "picture-in-picture" Axes Helper to visualize camera movements around the object. - */ - initSecondAxes() { - const length = 100; - const containerDimension = 100; - this.renderer2 = this.getWebGLRenderer({ alpha: true }); - this.renderer2.setClearColor("#FFFFFF", 0); - this.renderer2.setSize(containerDimension, containerDimension); - this.container.prepend(this.renderer2.domElement); - const origin = new TV3(0, 0, 0); - const [x, y, z] = [ - new THREE.ArrowHelper(new TV3(1, 0, 0), origin, length, "#FF0000", length / 3, length / 3), - new THREE.ArrowHelper(new TV3(0, 1, 0), origin, length, "#00FF00", length / 3, length / 3), - new THREE.ArrowHelper(new TV3(0, 0, 1), origin, length, "#0000FF", length / 3, length / 3), - ]; - // add axes to second scene to make stationary - this.scene2 = new THREE.Scene(); - this.camera2 = new THREE.PerspectiveCamera(50, 1, 1, 1000); - this.camera2.up = this.camera.up; - // saving axes helpers inside the scene object itself for further re-use in `hide*` method - this.scene2.x = x; - this.scene2.y = y; - this.scene2.z = z; - } - updateSecondAxes() { - this.camera2.position.copy(this.camera.position); - this.camera2.position.sub(this.orbitControls.target); - this.camera2.position.setLength(300); - this.camera2.lookAt(this.scene2.position); - this.render(); - } - showSecondAxes() { - const secondAxes = [this.scene2.x, this.scene2.y, this.scene2.z].filter((x) => x); // assert no `undefined`; - this.scene2.add(...secondAxes); - } - hideSecondAxes() { - const secondAxes = [this.scene2.x, this.scene2.y, this.scene2.z].filter((x) => x); // assert no `undefined`; - this.scene2.remove(...secondAxes); - } - /* - * Draws a "shooter-target" like object for aiming at the center of orbiting - * NOTE: not yet used, kept for the future. - */ - addTargetCrossToCamera() { - const TargetCrossHelper = new THREE.Mesh(new THREE.CircleGeometry(0.2, 32), new THREE.MeshBasicMaterial({ color: 0xffffff })); - TargetCrossHelper.position.copy(this.orbitControls.target.position); - this.camera.add(TargetCrossHelper); - } - /** - * Sets mouse cursor type. - * @param {String} cursorType - CSS Cursor attribute (https://developer.mozilla.org/en-US/docs/Web/CSS/cursor). - */ - setCursorStyle(cursorType) { - if (!cursorType) { - this.container.style.cursor = this.container.style.previousCursor || "default"; - } - else if (cursorType !== this.container.style.cursor) { - // avoid setting cursor to same value twice - this.container.style.previousCursor = this.container.style.cursor; - this.container.style.cursor = cursorType; - } - } -}; -/* - * Mixin containing the logic for enabling/disabling controls from key types. - * Holds the current state for the controls - ie. enabled/disabled - and initialized key event listeners. - */ -export const ControlsMixin = (superclass) => UtilsMixin(OrbitControlsMixin(class extends superclass { - constructor(config) { - super(config); - this.toggleOrbitControls = this.toggleOrbitControls.bind(this); - this.initControls(); - } - initControls() { - this.areOrbitControlsEnabled = false; - } - getControlsState() { - return { - areOrbitControlsEnabled: this.areOrbitControlsEnabled, - }; - } - setControlsState(s = {}) { - this.areOrbitControlsEnabled = s.areOrbitControlsEnabled || false; - } - toggleOrbitControls(skipStateUpdate = false) { - const initialState = this.getControlsState(); - this.toggleBoolean("areOrbitControlsEnabled"); - if (!skipStateUpdate) { - this.updateControlsFromState(initialState, this.getControlsState()); - } - } - /** - * Points the camera down a lattice vector, framing the whole cell. - * - * Reset View was the only camera command the viewer had (finding F12), though - * axis-aligned views are a primary control in VESTA and CrystalMaker. Paired with - * the orthographic toggle these give an exact projection, which is what makes a - * screen-plane drag an exact two-axis move (editor spec section 3a). - * - * `axis` is "a", "b", "c" or "111" (the body diagonal). Distance is recomputed to - * fit the cell rather than preserved, since the point is to frame the structure - * along that direction - unlike focusCameraOnSelection, which deliberately keeps - * the current viewing angle. - */ - setCameraAlongCellVector(axis) { - if (!this.orbitControls || !this._cell) - return; - const cell = this._cell; - const vectors = { - a: [cell.ax, cell.ay, cell.az], - b: [cell.bx, cell.by, cell.bz], - c: [cell.cx, cell.cy, cell.cz], - 111: [ - cell.ax + cell.bx + cell.cx, - cell.ay + cell.by + cell.cy, - cell.az + cell.bz + cell.cz, - ], - }; - const raw = vectors[axis]; - if (!raw || raw.some((component) => !Number.isFinite(component))) - return; - const direction = new TV3(...raw); - // A degenerate vector would put the camera on top of the target; keep the - // current view rather than producing a NaN transform. - if (direction.lengthSq() < 1e-9) - return; - direction.normalize(); - const { center, maxSize } = this.getCellViewParams(); - const target = new TV3(...center); - const size = Math.max(maxSize, 1); - let distance; - if (this.camera.isOrthographicCamera) { - this.setOrthographicCameraFrustum(this.PADDING_RATIO * size); - // Orthographic framing comes from the frustum, so the distance only has to - // clear the geometry. - distance = Math.max(size * 4, 10); - } - else { - const fovInRadians = (this.camera.fov * Math.PI) / 180; - distance = (this.PADDING_RATIO * size) / Math.tan(fovInRadians / 2); - } - this.camera.position.copy(target.clone().add(direction.multiplyScalar(distance))); - this.orbitControls.target.copy(target); - this.camera.lookAt(target); - this.orbitControls.update(); - this.render(); - } - updateControlsFromState(initialState, finalState) { - if (!this.areTwoObjectsShallowEqual(initialState, finalState)) { - const diffObject = this.getTwoObjectsShallowDifferentKeys(initialState, finalState); - if (diffObject.areOrbitControlsEnabled) { - if (this.areOrbitControlsEnabled) { - this.enableOrbitControls(); - } - else { - this.disableOrbitControls(); - } - } - } - this.render(); - } -})); diff --git a/dist/mixins/group_transform.d.ts b/dist/mixins/group_transform.d.ts deleted file mode 100644 index c4b91910..00000000 --- a/dist/mixins/group_transform.d.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index b1ba0dea..00000000 --- a/dist/mixins/group_transform.js +++ /dev/null @@ -1,145 +0,0 @@ -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. - */ - 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/image.d.ts b/dist/mixins/image.d.ts deleted file mode 100644 index d1eebe63..00000000 --- a/dist/mixins/image.d.ts +++ /dev/null @@ -1,68 +0,0 @@ -export function ImageMixin(superclass: any): { - new (): { - [x: string]: any; - takeScreenshot(): void; - getScreenshotImage(): any; - /** - * Largest drawing buffer this GL context will actually render, so an over-large request is - * scaled down before it becomes a blank image rather than after. - */ - getMaxFigureDimension(): number; - /** - * Points the renderer at an explicit pixel size and returns the function that puts it back. - * - * Shared by figure export and GIF recording, which have the same requirement - an output size - * that is stated rather than inherited from whatever the container happens to be - but - * different shapes, one synchronous and one an await loop over frames. Returning a restore - * callback lets both wrap it in their own try/finally rather than forcing one into the other's - * control flow. - */ - beginFixedRenderSize(width: any, height: any): () => void; - /** - * World units (Ångström) spanned by one image pixel, which is what a scale bar needs. - * - * Exact for the orthographic camera. For the perspective camera it is exact only in the - * plane through the orbit target, since a perspective projection has no single scale - the - * export dialog says so rather than presenting an approximation as a measurement. - */ - getWorldUnitsPerPixel(pixelHeight: any): number; - /** - * Recolours the viewer's chrome - text label sprites and chrome-coloured lines - and returns - * a function restoring every colour it changed. - * - * Atoms are Meshes, so element colours are left alone by construction. Label sprites hold - * near-white text in their texture, so multiplying by the target through `material.color` - * recolours the glyphs without redrawing any texture. - */ - applyFigureForeground(color: any): () => void; - /** - * Renders the scene once at an explicit size and background and returns a PNG data URL, - * leaving the on-screen viewer exactly as it was. - * - * Chrome is excluded for free: this reads the WebGL canvas, and the toolbars, status bar and - * inspector are DOM siblings of it, not part of the scene. - * - * @param width {Number} output width in pixels - * @param height {Number} output height in pixels - * @param background {String} a FigureBackgroundId - "viewer", "white" or "transparent" - * @param includeScaleBar {Boolean} draw a scale bar into the bottom-left corner - */ - getFigureImage({ width, height, background, includeScaleBar }?: number): any; - /** - * Copies the rendered frame onto a 2D canvas and draws the scale bar there. Drawing it into - * the scene instead would make it a 3D object subject to the projection - it has to be a - * fixed number of image pixels to mean anything. - */ - composeFigureWithScaleBar({ width, height, backgroundOption }: { - width: any; - height: any; - backgroundOption: any; - }): any; - /** Renders a figure and downloads it, named after the structure and the size used. */ - exportFigure(options?: {}): string; - updateScene(): Promise; - createRotatingGifData(options?: {}): Promise; - takeGifScreenshot(options?: {}): Promise; - }; - [x: string]: any; -}; diff --git a/dist/mixins/image.js b/dist/mixins/image.js deleted file mode 100644 index 3fb9a9b7..00000000 --- a/dist/mixins/image.js +++ /dev/null @@ -1,307 +0,0 @@ -import { showInfoAlert, showSuccessAlert, showWarningAlert } from "@mat3ra/cove/dist/other/alerts"; -import { saveImageDataToFile } from "@mat3ra/cove/dist/utils/downloader"; -import * as THREE from "three"; -import { DEFAULT_MAX_FIGURE_DIMENSION, drawScaleBar, getFigureBackground, getFigureFileName, getGifSide, getScaleBarPlan, } from "../utils/figureExport"; -import { createGIFAsync } from "./utils"; -/** - * Whether a line's colour is viewer chrome rather than data, for figure export (U-12). - * - * The rule is "light and achromatic": the unit cell is `#CCCCCC` and the axes indicator is - * `#FFFFFF`, both drawn to be seen against the dark viewer and both invisible on a white page. - * Anything with a hue is carrying meaning - boundary-condition lines are amber and blue by type - - * and anything dark already reads on a light background, so neither is touched. A property of the - * colour rather than a list of objects, so a new piece of chrome inherits it. - */ -function isChromeLineColor(color) { - const hsl = { h: 0, s: 0, l: 0 }; - color.getHSL(hsl); - return hsl.s < 0.05 && hsl.l > 0.6; -} -export const ImageMixin = (superclass) => class extends superclass { - takeScreenshot() { - saveImageDataToFile(this.getScreenshotImage()); - } - getScreenshotImage() { - // Reading back the canvas relies on the renderer being constructed with - // preserveDrawingBuffer: true (see WaveBase.initRenderer). There was a - // getContext("2d", { willReadFrequently: true }) call here, which returns null on a - // canvas that already holds a WebGL context and therefore did nothing at all. - return this.renderer.domElement.toDataURL("image/png"); - } - /** - * Largest drawing buffer this GL context will actually render, so an over-large request is - * scaled down before it becomes a blank image rather than after. - */ - getMaxFigureDimension() { - try { - const gl = this.renderer.getContext(); - const limits = [ - gl.getParameter(gl.MAX_RENDERBUFFER_SIZE), - gl.getParameter(gl.MAX_TEXTURE_SIZE), - ...(gl.getParameter(gl.MAX_VIEWPORT_DIMS) || []), - ].filter((value) => Number.isFinite(value) && value > 0); - return limits.length ? Math.min(...limits) : DEFAULT_MAX_FIGURE_DIMENSION; - } - catch (error) { - return DEFAULT_MAX_FIGURE_DIMENSION; - } - } - /** - * Points the renderer at an explicit pixel size and returns the function that puts it back. - * - * Shared by figure export and GIF recording, which have the same requirement - an output size - * that is stated rather than inherited from whatever the container happens to be - but - * different shapes, one synchronous and one an await loop over frames. Returning a restore - * callback lets both wrap it in their own try/finally rather than forcing one into the other's - * control flow. - */ - beginFixedRenderSize(width, height) { - const { renderer } = this; - const saved = { - width: this.WIDTH, - height: this.HEIGHT, - pixelRatio: renderer.getPixelRatio(), - }; - // Pinned to 1 so the output is the requested pixel count on a HiDPI display too. - renderer.setPixelRatio(1); - this.setViewportSize(width, height, false); - return () => { - renderer.setPixelRatio(saved.pixelRatio); - this.setViewportSize(saved.width, saved.height, false); - }; - } - /** - * World units (Ångström) spanned by one image pixel, which is what a scale bar needs. - * - * Exact for the orthographic camera. For the perspective camera it is exact only in the - * plane through the orbit target, since a perspective projection has no single scale - the - * export dialog says so rather than presenting an approximation as a measurement. - */ - getWorldUnitsPerPixel(pixelHeight) { - var _a; - if (!(pixelHeight > 0)) - return NaN; - const { camera } = this; - const zoom = camera.zoom || 1; - if (camera.isOrthographicCamera) { - return (camera.top - camera.bottom) / zoom / pixelHeight; - } - const target = ((_a = this.orbitControls) === null || _a === void 0 ? void 0 : _a.target) || new THREE.Vector3(); - const distance = camera.position.distanceTo(target); - const visibleHeight = 2 * distance * Math.tan(((camera.fov / 2) * Math.PI) / 180); - return visibleHeight / zoom / pixelHeight; - } - /** - * Recolours the viewer's chrome - text label sprites and chrome-coloured lines - and returns - * a function restoring every colour it changed. - * - * Atoms are Meshes, so element colours are left alone by construction. Label sprites hold - * near-white text in their texture, so multiplying by the target through `material.color` - * recolours the glyphs without redrawing any texture. - */ - applyFigureForeground(color) { - const target = new THREE.Color(color); - const originalColors = new Map(); - this.scene.traverse((object) => { - const isLabelSprite = Boolean(object.isSprite); - const isLine = Boolean(object.isLine || object.isLineSegments); - if (!isLabelSprite && !isLine) - return; - const materials = Array.isArray(object.material) - ? object.material - : [object.material]; - materials.forEach((material) => { - // Keyed by material because line materials are shared between segments: - // recording the same one twice would restore it to the export colour. - if (!material || !material.color || originalColors.has(material)) - return; - if (isLine && !isChromeLineColor(material.color)) - return; - originalColors.set(material, material.color.clone()); - material.color.copy(target); - }); - }); - return () => { - originalColors.forEach((originalColor, material) => { - material.color.copy(originalColor); - }); - }; - } - /** - * Renders the scene once at an explicit size and background and returns a PNG data URL, - * leaving the on-screen viewer exactly as it was. - * - * Chrome is excluded for free: this reads the WebGL canvas, and the toolbars, status bar and - * inspector are DOM siblings of it, not part of the scene. - * - * @param width {Number} output width in pixels - * @param height {Number} output height in pixels - * @param background {String} a FigureBackgroundId - "viewer", "white" or "transparent" - * @param includeScaleBar {Boolean} draw a scale bar into the bottom-left corner - */ - getFigureImage({ width, height, background = "viewer", includeScaleBar = false } = {}) { - const backgroundOption = getFigureBackground(background); - const { renderer } = this; - const savedClearColor = new THREE.Color(); - renderer.getClearColor(savedClearColor); - const saved = { - clearAlpha: renderer.getClearAlpha(), - sceneBackground: this.scene.background, - fogColor: this.scene.fog ? this.scene.fog.color.clone() : null, - }; - let restoreForeground = null; - let restoreSize = null; - try { - if (backgroundOption.foregroundColor) { - restoreForeground = this.applyFigureForeground(backgroundOption.foregroundColor); - } - // scene.background paints over the clear colour, so both have to move together - - // setting only the clear colour produces the viewer's dark grey regardless. - this.scene.background = - backgroundOption.clearAlpha === 0 - ? null - : new THREE.Color(backgroundOption.clearColor); - if (this.scene.fog) - this.scene.fog.color.set(backgroundOption.clearColor); - renderer.setClearColor(backgroundOption.clearColor, backgroundOption.clearAlpha); - restoreSize = this.beginFixedRenderSize(width, height); - return includeScaleBar - ? this.composeFigureWithScaleBar({ width, height, backgroundOption }) - : this.getScreenshotImage(); - } - finally { - if (restoreForeground) - restoreForeground(); - this.scene.background = saved.sceneBackground; - if (this.scene.fog && saved.fogColor) - this.scene.fog.color.copy(saved.fogColor); - renderer.setClearColor(savedClearColor, saved.clearAlpha); - if (restoreSize) - restoreSize(); - } - } - /** - * Copies the rendered frame onto a 2D canvas and draws the scale bar there. Drawing it into - * the scene instead would make it a 3D object subject to the projection - it has to be a - * fixed number of image pixels to mean anything. - */ - composeFigureWithScaleBar({ width, height, backgroundOption }) { - const plan = getScaleBarPlan({ - worldUnitsPerPixel: this.getWorldUnitsPerPixel(height), - imageWidth: width, - }); - if (!plan) - return this.getScreenshotImage(); - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext("2d"); - // No 2D context means no compositing is possible; the frame itself is still correct, so - // return it rather than failing the export over an annotation. - if (!context) - return this.getScreenshotImage(); - context.drawImage(this.renderer.domElement, 0, 0, width, height); - drawScaleBar(context, plan, { - width, - height, - // Dark chrome on a light page, light chrome on the viewer's own dark background. - color: backgroundOption.foregroundColor || "#EEEEEE", - }); - return canvas.toDataURL("image/png"); - } - /** Renders a figure and downloads it, named after the structure and the size used. */ - exportFigure(options = {}) { - var _a, _b; - const dataUrl = this.getFigureImage(options); - const fileName = getFigureFileName({ - name: (_a = this._structure) === null || _a === void 0 ? void 0 : _a.name, - formula: (_b = this._structure) === null || _b === void 0 ? void 0 : _b.formula, - width: options.width, - height: options.height, - backgroundId: options.background, - }); - saveImageDataToFile(dataUrl, fileName); - return fileName; - } - async updateScene() { - return new Promise((resolve) => { - const checkRender = () => { - this.renderer.render(this.scene, this.camera); // Ensure scene updates - requestAnimationFrame(() => resolve()); // Wait for the next frame - }; - checkRender(); - }); - } - async createRotatingGifData(options = {}) { - const sampleInterval = options.sampleInterval || 20; // Parts of image in pixels - const totalGifDuration = options.totalDuration || 3; // Seconds - const animationDuration = options.animationDuration || 1; // Seconds - const totalFrames = options.totalFrames || 60; // Number of frames in GIF - const autoRotateSpeed = 60 / animationDuration; // RPM - const frameDuration = totalGifDuration / totalFrames; - // `canvas.willReadFrequently = true/false` used to be set around this block; - // willReadFrequently is a getContext() attribute, not a canvas property, so those - // assignments only added an inert expando. - // - // The frames are captured at a fixed square instead of at the canvas's own size. Taking - // the canvas size made every GIF the shape of whoever's window recorded it - wide, - // letterboxed wherever it was embedded, and clipping the structure at the extremes of the - // rotation, since a turning structure sweeps through its own width. - const side = getGifSide({ - requested: options.size, - maxDimension: this.getMaxFigureDimension(), - }); - if (this.orbitControls.autoRotate) { - showWarningAlert("Please disable auto-rotation before creating a GIF."); - return null; - } - // Store original auto-rotate settings - const originalSpeed = this.orbitControls.autoRotateSpeed; - this.orbitControls.autoRotateSpeed = autoRotateSpeed; - this.orbitControls.autoRotate = true; - // try/finally so a throw mid-capture cannot leave the viewer spinning: the restore - // below used to be plain trailing statements, so any failure in frame capture or - // GIF encoding left autoRotate on at the modified speed for the rest of the session. - let restoreSize = this.beginFixedRenderSize(side, side); - try { - const frames = []; - for (let i = 0; i < totalFrames; i += 1) { - this.orbitControls.update(); // Move scene to new position - // eslint-disable-next-line no-await-in-loop - await this.updateScene(); // Wait for rendering to finish - frames.push(this.getScreenshotImage()); // Capture screenshot - } - // Put the viewer back *before* encoding, not after. The frames are already captured, - // so the square drawing buffer has done its job - and encoding 60 of them takes - // seconds, during which the canvas would otherwise still be showing a 512x512 buffer - // stretched across its on-screen box. Restoring here means the visible distortion - // lasts only as long as the capture itself. - restoreSize(); - restoreSize = null; - showInfoAlert("GIF is being created. Please wait..."); - return await createGIFAsync({ - images: frames, - gifWidth: side, - gifHeight: side, - sampleInterval, - frameDuration, - }); - } - finally { - // Restore original rotation settings - this.orbitControls.autoRotateSpeed = originalSpeed; - this.orbitControls.autoRotate = false; - // Still guarded: a throw during capture never reaches the restore above. - if (restoreSize) - restoreSize(); - } - } - async takeGifScreenshot(options = {}) { - const gifDataUrl = await this.createRotatingGifData(options); - if (!gifDataUrl) - return; - const fileName = (this._structure.name || this._structure.formula || "wave-visualization") + ".gif"; - showSuccessAlert("GIF is created. Proceeding to download."); - saveImageDataToFile(gifDataUrl, fileName); - } -}; diff --git a/dist/mixins/interactive_editor_constants.d.ts b/dist/mixins/interactive_editor_constants.d.ts deleted file mode 100644 index 01e6b897..00000000 --- a/dist/mixins/interactive_editor_constants.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * 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 deleted file mode 100644 index fb68a29b..00000000 --- a/dist/mixins/interactive_editor_constants.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * 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 deleted file mode 100644 index 45288ea2..00000000 --- a/dist/mixins/interactive_structure_editor.d.ts +++ /dev/null @@ -1,282 +0,0 @@ -import * as THREE from "three"; -import { TransformControls } from "three/examples/jsm/controls/TransformControls"; -type Coordinate3D = [number, number, number]; -/** - * 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. - */ -export declare const InteractiveStructureEditorMixin: (superclass: any) => { - new (config: any): { - [x: string]: any; - transformControls_: TransformControls | null; - transformDragStartPosition_: THREE.Vector3 | null; - transformDragStartQuaternion_: THREE.Quaternion | null; - raycaster_: THREE.Raycaster | null; - pointer_: THREE.Vector2 | null; - selectedMesh_: THREE.Mesh | null; - selectedMeshes_: THREE.Mesh[]; - hoveredMesh_: THREE.Mesh | null; - selectionHighlightPool_: THREE.Mesh[]; - hoverHighlightMesh_: THREE.Mesh | null; - isEditModeEnabled_: boolean; - pointerDownPosition_: { - x: number; - y: number; - } | null; - pendingDragAtom_: THREE.Mesh | null; - isDraggingAtom_: boolean; - dragPlane_: THREE.Plane | null; - dragOffset_: THREE.Vector3 | null; - dragStartPosition_: THREE.Vector3 | null; - activePointerId_: number | null; - orbitControlsEnabledBeforeDrag_: boolean; - orbitControlsDefaultMouseButtons_: any | null; - orbitControlsDefaultTouches_: any | null; - lastSelectedAtomicIndices_: number[] | null; - handlePointerDownCapture_: ((event: PointerEvent) => void) | null; - handlePointerMoveCapture_: ((event: PointerEvent) => void) | null; - handlePointerUpCapture_: ((event: PointerEvent) => void) | null; - handlePointerCancelCapture_: ((event: PointerEvent) => void) | null; - handleEditModeKeyDown_: ((event: KeyboardEvent) => void) | null; - /** - * Initializes the Three.js TransformControls, adds them to the scene, and binds drag lifecycle listeners. - * Dragging updates temporary camera locks to avoid rotation conflicts. - */ - initializeEditor(): void; - /** - * Creates a camera-agnostic highlight halo: a slightly larger wireframe sphere layered - * around an atom. Kept as a dedicated object with its own material rather than mutating - * the atom mesh's own material.emissive, which spin-glow and measurement-hover already - * write to - sharing that channel means selecting/deselecting an atom would erase - * whichever of those effects got there first. - */ - createHighlightMesh_(color: number, opacity: number): THREE.Mesh; - /** - * Positions and shows/hides a halo mesh around the given atom (or hides it if null). - */ - updateHighlightMesh_(haloMesh: THREE.Mesh | null, atomMesh: THREE.Mesh | null): void; - /** - * Keeps exactly one visible halo per selected atom, reusing a pool rather than - * creating/disposing geometry on every selection change or drag-frame update. - */ - syncHighlightPoolTo_(meshes: THREE.Mesh[]): void; - /** - * Initializes the pointer vector and Raycaster used both for click-to-select and for - * direct click-and-drag: pressing down on an atom and moving the pointer drags that atom - * in the camera-facing plane immediately, without needing to separately grab a gizmo - * handle first. A plain click (press+release with negligible movement) still only - * selects, via handlePointerDown() below, matching the original click-to-select behavior; - * the gizmo remains available afterwards for precise axis-constrained edits. - * - * Pressing down on empty space starts a marquee-select instead (see - * updateMarqueeState_/finishMarqueeSelection_): orbiting the camera moves to a - * right-mouse-drag while in edit mode (see enableEditMode) so the two gestures don't - * collide on the same button (decision D-4). - */ - initializeSelectionRaycaster(): void; - /** - * Updates the hover halo/cursor to whatever atom (if any) is under the pointer. Only - * called while not already dragging/pending, so hover tracking never adds raycasts to - * the hot path of an in-progress drag. - */ - updateHoverFromPointer_(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 - * pressed atom is already part of a multi-selection, the whole selection drags together - * rigidly (matching standard multi-select conventions) instead of collapsing to just the - * pressed atom. - */ - beginAtomDrag_(event: PointerEvent): void; - /** - * Finalizes a direct atom (or group) drag: restores camera orbiting and commits the - * moved atom(s) as a single delta applied to the current material (see - * commitMovedAtom_/commitMovedAtoms_) - one history entry regardless of how many atoms - * moved. - */ - endAtomDrag_(): void; - /** - * Abandons an in-progress direct atom (or group) drag (Esc / pointercancel): snaps every - * dragged atom back to its pre-drag position and reports nothing - no history entry, no - * callback. - */ - cancelAtomDrag_(): void; - releaseActiveDragPointer_(): void; - /** - * Points this.raycaster_ from the camera through the given pointer event's position, - * converted to Normalized Device Coordinates (NDC). - */ - updateRaycasterFromPointer_(event: PointerEvent): void; - /** - * Raycasts from the given pointer event through the atoms in the scene. - * @returns the frontmost hit atom mesh, or null if none was hit. - */ - getAtomAtPointer(event: PointerEvent): THREE.Mesh | null; - /** - * Raycasts from the given pointer event and intersects it with the given plane. - * @returns the intersection point, or null if the ray is parallel to the plane. - */ - getPointerPlaneIntersection(event: PointerEvent, plane: THREE.Plane): THREE.Vector3 | null; - /** - * Handles a plain click (no drag) on the canvas to select an atom mesh and attach the - * transform gizmo, or to deselect when clicking empty space. Shift+click adds to the - * current selection, Ctrl/Cmd+click toggles a single atom in/out of it; Shift/Ctrl+click - * on empty space is a no-op (the current selection is left alone rather than being - * surprisingly cleared mid-multi-select). - * @param {PointerEvent} event - Native browser pointer event. - */ - handlePointerDown(event: PointerEvent): void; - /** - * Highlights the given atom mesh and attaches the transform gizmo to it. Thin - * single-atom wrapper over setSelectedAtomMeshes, kept for callers/tests that only ever - * deal with one atom at a time. - */ - setSelectedAtomMesh(atomMesh: THREE.Mesh): void; - /** - * Sets the full multi-atom selection: highlights every selected atom via the halo pool, - * and attaches the gizmo directly to the sole atom (single selection) or to a pivot at - * the selection's centroid (2+ atoms), so a group drag moves every selected atom rigidly - * (see beginAtomDrag_/the TransformControls "change"/"mouseUp" listeners). - */ - setSelectedAtomMeshes(meshes: THREE.Mesh[]): void; - /** - * Clears the current atom selection, removes its highlight(s), and detaches the gizmo. - * @param {boolean} forgetLastSelection - Also forget the remembered indices used to - * restore selection when edit mode is re-enabled (see enableEditMode). Defaults to true - * for an explicit user deselect; enableEditMode(false) passes false so toggling edit - * mode off and back on preserves the selection (R12). - */ - clearSelection(forgetLastSelection?: boolean): void; - /** - * Single-atom-named alias for clearSelection, kept for existing callers/tests. - */ - clearSelectedAtom(forgetLastSelection?: boolean): void; - /** - * Re-attaches the selection/gizmo to the atom meshes with the given atomicIndices. - * Used after a scene rebuild, since rebuilding replaces every atom mesh instance and - * would otherwise leave the gizmo attached to mesh(es) that no longer exist in the scene. - * Clears the selection if none of the given indices match any atom any more (e.g. it was - * deleted). Indices with no match are silently dropped rather than clearing everything, - * so removing one atom out of a multi-selection keeps the rest selected. - * - * The sole place that notifies onSelectionChanged for a reselect, and only when the - * result actually differs from what was selected going in - critically, this covers - * rebuildScene() (wave.js), which calls this on every rebuild (including one triggered by - * undo/redo, which can shift or remove indices out from under an unrelated rebuild) but - * has no way to notify the host itself. Without this, undo/redo across an add/remove - * boundary left the host's selection state pointing at an atomicIndex that had silently - * stopped existing on the wave side. Callers that already know their own new selection - * (addAtom, cloneSelectedAtoms, enableEditMode's restore) used to fire this explicitly - * too; that's now redundant and has been removed to keep this the single source of truth. - */ - reselectAtomsByIndices(atomicIndices: Array | null | undefined): void; - /** - * Single-index-named alias for reselectAtomsByIndices, kept for existing callers/tests. - */ - reselectAtomByIndex(atomicIndex: number | null | undefined): void; - /** - * Enables or disables edit mode interactions and controls visibility. Disabling - * preserves the selection's indices (see clearSelection) so re-enabling edit mode - * restores it (R12) rather than always starting deselected. While enabled, remaps the - * OrbitControls left mouse button off (freeing it for marquee-select on empty space) and - * moves camera rotation onto the right mouse button (decision D-4); the defaults are - * restored on disable. - * - * Touch gets the same treatment for the same reason (U-13). OrbitControls' default - * `touches.ONE` is ROTATE, which is the finger the editor needs for selecting, dragging an - * atom and marquee-selecting - so while edit mode is on, one finger belongs to the editor and - * the camera moves to two fingers (DOLLY_ROTATE: pinch to zoom, twist to orbit). There is no - * right button to move it to. - * @param {boolean} enabled - True to enable, false to disable. - */ - enableEditMode(enabled: boolean): void; - /** - * Shorthand for disabling edit mode. - */ - disableEditMode(): void; - /** - * Updates the TransformControls mode (translate, rotate). - * @param {string} mode - Mode name ("translate" or "rotate"). - */ - setTransformMode(mode: "translate" | "rotate"): void; - /** - * Re-points the edit gizmo at the newly active camera so dragging keeps working - * after the user switches between perspective and orthographic projection. - */ - toggleOrthographicCamera(): void; - /** - * Applies a basis mutation to a clone of the wave's own current structure, preserving - * its lattice, units, metadata, labels, and constraints exactly - only the fields the - * callback actually touches change. This replaces the old approach of re-deriving the - * whole material from the live Three.js scene on every edit (ThreeDSceneDataToMaterial), - * which was a lossy round trip: it reintroduced the lattice to only ~1e-7 precision - * (causing spurious camera resets, D1) and could pick up bond/boundary/repetition meshes - * as phantom atoms (D5) - a class of bug that a delta applied to the known material - * cannot reintroduce, because the lattice and every untouched atom are never recomputed. - */ - applyBasisDelta_(mutateBasis: (basis: any) => void): any; - /** - * Adds an atom to the structure and triggers scene reconstruction. The new atom is - * auto-selected once the scene has rebuilt around the updated material. - * @param {string} elementName - Chemical element symbol (e.g. "Si"). - * @param {Array} cartesianCoordinates - [x, y, z] position in Cartesian space. - */ - addAtom(elementName: string, cartesianCoordinates: Coordinate3D): void; - /** - * Removes the currently selected atom(s) from the structure - the whole multi-selection - * if 2+ atoms are selected (as one commit, one history entry), or the single selected - * atom otherwise. - */ - removeSelectedAtom(): void; - /** - * Removes every currently multi-selected atom as a single delta/commit. - */ - removeSelectedAtoms_(): void; - /** - * Duplicates every selected atom at a small offset from its source, preserving element - * and (for the group case) relative positions, as a single commit. The clones become the - * new selection, matching Add Atom's auto-select behavior. Old-editor parity: its "clone - * existing" was one of only two ways to add an atom of a specific element, the other - * being a plain add-then-rename (see changeAtomElement / D-9). - * - * Shares Add Atom's occupied-site guard (D22): the offset nudges further along the same - * diagonal until clear of every existing atom (and of any sibling clone already placed - * earlier in this same call, so cloning several selected atoms at once can't collide with - * each other either), so cloning the same atom repeatedly doesn't silently stack - * coincident duplicates. - */ - cloneSelectedAtoms(): void; - /** - * Frames the camera on the current selection's bounding sphere, preserving the current - * viewing angle (only re-targeting and re-distancing, not resetting to a canonical - * axis-aligned view like adjustCamerasAndOrbitControlsToCell does for the whole cell). - * The one camera move an edit-mode interaction is allowed to make, since it's a direct, - * explicit user action (F key) rather than a side effect of an edit (US-12). - */ - focusCameraOnSelection(): void; - /** - * Commits a single moved atom (from either a direct drag or a gizmo drag) as one delta - * applied to the current material, then rebuilds the scene around it. rebuildScene() - * itself preserves the selection/gizmo across the rebuild when in edit mode (see - * wave.js), so no explicit reselect is needed here for the move case. - */ - commitMovedAtom_(atomicIndex: number, cartesianPosition: THREE.Vector3, source: string): void; - /** - * Commits any number of moved atoms as a single delta/commit (one history entry) applied - * to the current material, then rebuilds the scene around it. `source` (spec Sec6.2's - * onEditCommit contract - "drag" for a direct body-drag, "gizmo" for a TransformControls - * drag) is forwarded to onStructureModified so ThreeDEditor.jsx can pass it on to a host's - * onEditCommit without having to re-infer which gesture produced this commit. - */ - commitMovedAtoms_(moves: Array<{ - atomicIndex: number; - position: THREE.Vector3; - }>, source: string): void; - /** - * Lifecycle hook to dispose event listeners and objects on visualizer destruction. - */ - dispose(): void; - }; - [x: string]: any; -}; -export {}; diff --git a/dist/mixins/interactive_structure_editor.js b/dist/mixins/interactive_structure_editor.js deleted file mode 100644 index 8a6ebe35..00000000 --- a/dist/mixins/interactive_structure_editor.js +++ /dev/null @@ -1,1071 +0,0 @@ -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_COMMIT_EPSILON = 1e-6; -/** - * Value put in OrbitControls' `touches.ONE` while edit mode owns the first finger (U-13). - * - * OrbitControls has no "no gesture" constant for touch the way `mouseButtons` accepts null: its - * `onTouchStart` switches on `touches.ONE` over TOUCH.ROTATE and TOUCH.PAN and falls through to - * `STATE.NONE` for anything else. Deliberately outside the enum (TOUCH runs 0-3), and named so the - * next reader does not "fix" it into a real gesture. - */ -const ONE_FINGER_RESERVED_FOR_EDITING = -1; -/** - * 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. - */ -export const InteractiveStructureEditorMixin = (superclass) => class extends superclass { - constructor(config) { - super(config); - this.transformControls_ = null; - this.transformDragStartPosition_ = null; - this.transformDragStartQuaternion_ = null; - this.raycaster_ = null; - this.pointer_ = null; - this.selectedMesh_ = null; - this.selectedMeshes_ = []; - this.hoveredMesh_ = null; - this.selectionHighlightPool_ = []; - this.hoverHighlightMesh_ = null; - this.isEditModeEnabled_ = false; - this.pointerDownPosition_ = null; - this.pendingDragAtom_ = null; - this.isDraggingAtom_ = false; - this.dragPlane_ = null; - this.dragOffset_ = null; - this.dragStartPosition_ = null; - this.activePointerId_ = null; - this.orbitControlsEnabledBeforeDrag_ = true; - this.orbitControlsDefaultMouseButtons_ = null; - this.orbitControlsDefaultTouches_ = null; - this.lastSelectedAtomicIndices_ = null; - this.handlePointerDownCapture_ = null; - this.handlePointerMoveCapture_ = null; - this.handlePointerUpCapture_ = null; - this.handlePointerCancelCapture_ = null; - this.handleEditModeKeyDown_ = null; - this.initializeEditor = this.initializeEditor.bind(this); - this.initializeSelectionRaycaster = this.initializeSelectionRaycaster.bind(this); - this.handlePointerDown = this.handlePointerDown.bind(this); - this.setTransformMode = this.setTransformMode.bind(this); - this.addAtom = this.addAtom.bind(this); - this.removeSelectedAtom = this.removeSelectedAtom.bind(this); - this.cloneSelectedAtoms = this.cloneSelectedAtoms.bind(this); - this.focusCameraOnSelection = this.focusCameraOnSelection.bind(this); - this.enableEditMode = this.enableEditMode.bind(this); - this.disableEditMode = this.disableEditMode.bind(this); - this.toggleOrthographicCamera = this.toggleOrthographicCamera.bind(this); - // Setup editor components after parent class setup is complete - this.initializeEditor(); - this.initializeSelectionRaycaster(); - } - /** - * Initializes the Three.js TransformControls, adds them to the scene, and binds drag lifecycle listeners. - * Dragging updates temporary camera locks to avoid rotation conflicts. - */ - initializeEditor() { - this.transformControls_ = new TransformControls(this.camera, this.renderer.domElement); - this.scene.add(this.transformControls_); - this.selectionPivot_ = new THREE.Object3D(); - this.scene.add(this.selectionPivot_); - 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, 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", () => { - this.handleGroupPivotChange_(); - this.render(); - }); - // Disables the OrbitControls while dragging an atom to avoid camera movement conflicts - this.transformControls_.addEventListener("dragging-changed", (event) => { - var _a, _b, _c, _d, _e, _f, _g, _h, _j; - if (event.value) { - this.orbitControlsEnabledBeforeDrag_ = (_b = (_a = this.orbitControls) === null || _a === void 0 ? void 0 : _a.enabled) !== null && _b !== void 0 ? _b : true; - if (this.orbitControls) - this.orbitControls.enabled = false; - // Snapshot the object's position when a drag starts so mouseUp can tell - // whether the gizmo actually moved anything (a zero-movement click-release - // must not commit), and Esc can revert to it. - this.transformDragStartPosition_ = - (_e = (_d = (_c = this.transformControls_) === null || _c === void 0 ? void 0 : _c.object) === null || _d === void 0 ? void 0 : _d.position.clone()) !== null && _e !== void 0 ? _e : null; - 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.captureGroupDragStartPositions_(); - } - } - else { - if (this.orbitControls) { - this.orbitControls.enabled = this.orbitControlsEnabledBeforeDrag_; - } - this.transformDragStartPosition_ = null; - this.transformDragStartQuaternion_ = null; - } - }); - // Rerender scene and trigger callbacks when the drag operation completes - this.transformControls_.addEventListener("mouseUp", () => { - var _a, _b; - const draggedObject = (_a = this.transformControls_) === null || _a === void 0 ? void 0 : _a.object; - const startPosition = this.transformDragStartPosition_; - const startQuaternion = this.transformDragStartQuaternion_; - const wasRotate = ((_b = this.transformControls_) === null || _b === void 0 ? void 0 : _b.mode) === "rotate"; - const hasMoved = !!draggedObject && - !!startPosition && - draggedObject.position.distanceTo(startPosition) > DRAG_COMMIT_EPSILON; - // A pure rotation about the pivot's own (fixed) position never changes - // draggedObject.position, so a rotate-mode commit must be detected via the - // quaternion instead - hasMoved alone would never fire for it. - const hasRotated = !!draggedObject && - !!startQuaternion && - draggedObject.quaternion.angleTo(startQuaternion) > DRAG_COMMIT_EPSILON; - if ((hasMoved || hasRotated) && draggedObject) { - if (draggedObject === this.selectionPivot_) { - this.commitGroupGizmoDrag_(wasRotate); - } - else { - this.commitMovedAtom_(draggedObject.userData.atomicIndex, draggedObject.position, "gizmo"); - } - } - this.groupDragStartPositions_ = null; - }); - } - /** - * Creates a camera-agnostic highlight halo: a slightly larger wireframe sphere layered - * around an atom. Kept as a dedicated object with its own material rather than mutating - * the atom mesh's own material.emissive, which spin-glow and measurement-hover already - * write to - sharing that channel means selecting/deselecting an atom would erase - * whichever of those effects got there first. - */ - createHighlightMesh_(color, opacity) { - const geometry = new THREE.SphereGeometry(1, 16, 16); - const material = new THREE.MeshBasicMaterial({ - color, - transparent: true, - opacity, - wireframe: true, - depthWrite: false, - }); - const mesh = new THREE.Mesh(geometry, material); - mesh.visible = false; - // Never itself a raycast/pick target. - // eslint-disable-next-line @typescript-eslint/no-empty-function - mesh.raycast = () => { }; - return mesh; - } - /** - * Positions and shows/hides a halo mesh around the given atom (or hides it if null). - */ - updateHighlightMesh_(haloMesh, atomMesh) { - if (!haloMesh) - return; - if (!atomMesh) { - haloMesh.visible = false; - return; - } - haloMesh.position.copy(atomMesh.position); - const radius = atomMesh.scale.x * HIGHLIGHT_SCALE_FACTOR; - haloMesh.scale.set(radius, radius, radius); - haloMesh.visible = true; - } - /** - * Keeps exactly one visible halo per selected atom, reusing a pool rather than - * creating/disposing geometry on every selection change or drag-frame update. - */ - syncHighlightPoolTo_(meshes) { - while (this.selectionHighlightPool_.length < meshes.length) { - const halo = this.createHighlightMesh_(SELECTION_HIGHLIGHT_COLOR, 0.9); - this.scene.add(halo); - this.selectionHighlightPool_.push(halo); - } - this.selectionHighlightPool_.forEach((halo, index) => { - var _a; - this.updateHighlightMesh_(halo, (_a = meshes[index]) !== null && _a !== void 0 ? _a : null); - }); - } - /** - * Initializes the pointer vector and Raycaster used both for click-to-select and for - * direct click-and-drag: pressing down on an atom and moving the pointer drags that atom - * in the camera-facing plane immediately, without needing to separately grab a gizmo - * handle first. A plain click (press+release with negligible movement) still only - * selects, via handlePointerDown() below, matching the original click-to-select behavior; - * the gizmo remains available afterwards for precise axis-constrained edits. - * - * Pressing down on empty space starts a marquee-select instead (see - * updateMarqueeState_/finishMarqueeSelection_): orbiting the camera moves to a - * right-mouse-drag while in edit mode (see enableEditMode) so the two gestures don't - * collide on the same button (decision D-4). - */ - initializeSelectionRaycaster() { - this.raycaster_ = new THREE.Raycaster(); - this.pointer_ = new THREE.Vector2(); - this.handlePointerDownCapture_ = (event) => { - var _a; - // Only the primary (left) button starts a selection, drag, or marquee; a - // PointerEvent constructed without an explicit button (as synthetic test events - // often are) defaults to 0 per spec, so only reject an EXPLICIT non-zero button. - if (event.button !== undefined && event.button !== 0) - return; - this.pointerDownPosition_ = { x: event.clientX, y: event.clientY }; - this.pendingDragAtom_ = null; - this.isDraggingAtom_ = false; - this.activePointerId_ = (_a = event.pointerId) !== null && _a !== void 0 ? _a : null; - if (!this.isEditModeEnabled_) - return; - // Let TransformControls handle its own gizmo-handle drags exclusively - if (this.transformControls_ && this.transformControls_.dragging) - return; - this.pendingDragAtom_ = this.getAtomAtPointer(event); - if (!this.pendingDragAtom_) { - this.marqueeStartScreen_ = { x: event.clientX, y: event.clientY }; - this.marqueeModifierAdd_ = event.shiftKey; - this.marqueeModifierToggle_ = event.ctrlKey || event.metaKey; - } - }; - this.handlePointerMoveCapture_ = (event) => { - if (this.marqueeStartScreen_) { - this.updateMarqueeState_(event); - return; - } - if (this.isEditModeEnabled_ && !this.pendingDragAtom_ && !this.isDraggingAtom_) { - this.updateHoverFromPointer_(event); - } - if (!this.pendingDragAtom_) - return; - if (!this.isDraggingAtom_) { - if (!this.pointerDownPosition_) - return; - const distance = Math.sqrt((event.clientX - this.pointerDownPosition_.x) ** 2 + - (event.clientY - this.pointerDownPosition_.y) ** 2); - // Only commit to a drag once the pointer has moved a few pixels, so a plain - // click still falls through to handlePointerUpCapture_'s select-only path. - if (distance < DRAG_THRESHOLD_PX) - return; - this.beginAtomDrag_(event); - } - if (!this.dragPlane_ || !this.dragOffset_) - return; - const point = this.getPointerPlaneIntersection(event, this.dragPlane_); - if (!point) - return; - const newPosition = point.add(this.dragOffset_); - // 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_); - this.render(); - }; - this.handlePointerUpCapture_ = (event) => { - if (this.marqueeStartScreen_) { - this.finishMarqueeSelection_(event); - return; - } - if (this.isDraggingAtom_ && this.pendingDragAtom_) { - this.endAtomDrag_(); - return; - } - this.pendingDragAtom_ = null; - if (!this.pointerDownPosition_) - return; - const distance = Math.sqrt((event.clientX - this.pointerDownPosition_.x) ** 2 + - (event.clientY - this.pointerDownPosition_.y) ** 2); - // Perform raycasting selection only if pointer movement is negligible (less than - // DRAG_THRESHOLD_PX) - if (distance < DRAG_THRESHOLD_PX) { - this.handlePointerDown(event); - } - this.pointerDownPosition_ = null; - }; - // A pointercancel (browser/OS interrupts the gesture - e.g. a tab switch mid-drag) - // must abandon the drag exactly like Esc: revert position, commit nothing. - this.handlePointerCancelCapture_ = () => { - if (this.marqueeStartScreen_) { - this.hideMarqueeOverlay_(); - this.marqueeStartScreen_ = null; - this.isMarqueeSelecting_ = false; - } - else if (this.isDraggingAtom_) { - this.cancelAtomDrag_(); - } - else { - this.pendingDragAtom_ = null; - this.pointerDownPosition_ = null; - } - }; - this.renderer.domElement.addEventListener("pointerdown", this.handlePointerDownCapture_); - this.renderer.domElement.addEventListener("pointermove", this.handlePointerMoveCapture_); - this.renderer.domElement.addEventListener("pointerup", this.handlePointerUpCapture_); - this.renderer.domElement.addEventListener("pointercancel", this.handlePointerCancelCapture_); - this.handleEditModeKeyDown_ = (event) => { - var _a, _b; - if (!this.isEditModeEnabled_) - return; - // Don't act on Escape/F while the user is typing in a form field (e.g. the - // coordinate panel or the element-rename field) - "f" in particular is a normal - // character a user might type there (e.g. renaming an atom to "Fe"). - const target = event.target; - if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.nodeName)) - return; - if (event.key === "Escape") { - if (this.isDraggingAtom_ || ((_a = this.transformControls_) === null || _a === void 0 ? void 0 : _a.dragging)) { - this.cancelAtomDrag_(); - } - else if (this.selectedMeshes_.length > 0) { - this.clearSelection(); - if (this.settings.onSelectionChanged) { - this.settings.onSelectionChanged([]); - } - this.render(); - } - } - else if (event.key.toLowerCase() === - ((_b = this.settings.hotKeysConfig) === null || _b === void 0 ? void 0 : _b.focusCameraOnSelection) && - this.selectedMeshes_.length > 0) { - this.focusCameraOnSelection(); - } - }; - document.addEventListener("keydown", this.handleEditModeKeyDown_); - } - /** - * Updates the hover halo/cursor to whatever atom (if any) is under the pointer. Only - * called while not already dragging/pending, so hover tracking never adds raycasts to - * the hot path of an in-progress drag. - */ - updateHoverFromPointer_(event) { - const atomUnderPointer = this.getAtomAtPointer(event); - if (atomUnderPointer === this.hoveredMesh_) - return; - this.hoveredMesh_ = atomUnderPointer; - this.updateHighlightMesh_(this.hoverHighlightMesh_, atomUnderPointer); - this.renderer.domElement.style.cursor = atomUnderPointer ? "move" : ""; - 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 - * pressed atom is already part of a multi-selection, the whole selection drags together - * rigidly (matching standard multi-select conventions) instead of collapsing to just the - * pressed atom. - */ - beginAtomDrag_(event) { - var _a, _b; - if (!this.pendingDragAtom_) - return; - this.isDraggingAtom_ = true; - this.dragStartPosition_ = this.pendingDragAtom_.position.clone(); - this.isDraggingGroup_ = - this.selectedMeshes_.length > 1 && - this.selectedMeshes_.includes(this.pendingDragAtom_); - if (this.isDraggingGroup_) { - this.captureGroupDragStartPositions_(); - } - else { - this.groupDragStartPositions_ = null; - this.setSelectedAtomMesh(this.pendingDragAtom_); - if (this.settings.onSelectionChanged) { - this.settings.onSelectionChanged([this.pendingDragAtom_.userData.atomicIndex]); - } - } - this.orbitControlsEnabledBeforeDrag_ = (_b = (_a = this.orbitControls) === null || _a === void 0 ? void 0 : _a.enabled) !== null && _b !== void 0 ? _b : true; - if (this.orbitControls) - this.orbitControls.enabled = false; - if (this.activePointerId_ !== null && - typeof this.renderer.domElement.setPointerCapture === "function") { - this.renderer.domElement.setPointerCapture(this.activePointerId_); - } - this.hoveredMesh_ = null; - this.updateHighlightMesh_(this.hoverHighlightMesh_, null); - this.renderer.domElement.style.cursor = "grabbing"; - const cameraDirection = new THREE.Vector3(); - this.camera.getWorldDirection(cameraDirection); - this.dragPlane_ = new THREE.Plane().setFromNormalAndCoplanarPoint(cameraDirection, this.pendingDragAtom_.position); - const startPoint = this.getPointerPlaneIntersection(event, this.dragPlane_); - this.dragOffset_ = startPoint - ? this.pendingDragAtom_.position.clone().sub(startPoint) - : new THREE.Vector3(); - } - /** - * Finalizes a direct atom (or group) drag: restores camera orbiting and commits the - * moved atom(s) as a single delta applied to the current material (see - * commitMovedAtom_/commitMovedAtoms_) - one history entry regardless of how many atoms - * moved. - */ - endAtomDrag_() { - const atom = this.pendingDragAtom_; - const wasGroup = this.isDraggingGroup_; - const groupMeshes = wasGroup ? [...this.selectedMeshes_] : null; - this.releaseActiveDragPointer_(); - this.isDraggingAtom_ = false; - this.isDraggingGroup_ = false; - this.groupDragStartPositions_ = null; - this.pendingDragAtom_ = null; - this.pointerDownPosition_ = null; - this.dragStartPosition_ = null; - if (this.orbitControls) - this.orbitControls.enabled = this.orbitControlsEnabledBeforeDrag_; - this.renderer.domElement.style.cursor = ""; - if (wasGroup && groupMeshes) { - this.commitMovedAtoms_(groupMeshes.map((mesh) => ({ - atomicIndex: mesh.userData.atomicIndex, - position: mesh.position.clone(), - })), "drag"); - } - else if (atom) { - this.commitMovedAtom_(atom.userData.atomicIndex, atom.position, "drag"); - } - } - /** - * Abandons an in-progress direct atom (or group) drag (Esc / pointercancel): snaps every - * dragged atom back to its pre-drag position and reports nothing - no history entry, no - * callback. - */ - cancelAtomDrag_() { - var _a, _b, _c; - // Covers all four drag shapes this mixin supports: a direct body-drag on one atom or - // on a multi-selected group (isDraggingGroup_/pendingDragAtom_ below), and the - // gizmo-driven equivalent of either (a lone atom or the group pivot attached to - // TransformControls, handled here via draggedObject). Without this branch, Esc during - // a gizmo drag - translate mode's default interaction - reverted nothing: it silently - // continued dragging (TransformControls had no idea Esc was pressed), and on the - // eventual real mouseup committed a delta the user had just tried to cancel. - const draggedObject = ((_a = this.transformControls_) === null || _a === void 0 ? void 0 : _a.dragging) - ? this.transformControls_.object - : null; - if (draggedObject === this.selectionPivot_ && this.groupDragStartPositions_) { - 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); - }); - if (this.transformDragStartPosition_) { - (_b = this.selectionPivot_) === null || _b === void 0 ? void 0 : _b.position.copy(this.transformDragStartPosition_); - } - // Harmless no-op for a cancelled translate (the pivot's quaternion never left - // identity); for a cancelled rotate this is the actual revert, mirroring the - // commit path's own post-rotate reset. - (_c = this.selectionPivot_) === null || _c === void 0 ? void 0 : _c.quaternion.identity(); - this.syncHighlightPoolTo_(this.selectedMeshes_); - this.render(); - } - else if (draggedObject && this.transformDragStartPosition_) { - draggedObject.position.copy(this.transformDragStartPosition_); - if (this.transformDragStartQuaternion_) { - draggedObject.quaternion.copy(this.transformDragStartQuaternion_); - } - this.syncHighlightPoolTo_(this.selectedMeshes_); - this.render(); - } - else if (this.isDraggingGroup_ && this.groupDragStartPositions_) { - 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); - }); - this.syncHighlightPoolTo_(this.selectedMeshes_); - this.render(); - } - else if (this.pendingDragAtom_ && this.dragStartPosition_) { - this.pendingDragAtom_.position.copy(this.dragStartPosition_); - this.syncHighlightPoolTo_(this.selectedMeshes_); - this.render(); - } - // Setting dragging = false is a no-op if it's already false (TransformControls' own - // property setter only dispatches when the value actually changes - see its - // defineProperty helper), so this is safe to call unconditionally even for a - // non-gizmo cancel. For a real gizmo cancel, it's also sufficient on its own to halt - // TransformControls' further internal pointerMove/pointerUp handling - both - // early-return once `dragging` reads false, per its source - even though the real - // mouse button may still be physically held; the auto-fired "dragging-changed" event - // this triggers is what restores orbitControls.enabled and clears - // transformDragStartPosition_/Quaternion_ below (read above, before this point). - if (this.transformControls_) - this.transformControls_.dragging = false; - this.releaseActiveDragPointer_(); - this.isDraggingAtom_ = false; - this.isDraggingGroup_ = false; - this.groupDragStartPositions_ = null; - this.pendingDragAtom_ = null; - this.pointerDownPosition_ = null; - this.dragStartPosition_ = null; - if (this.orbitControls) - this.orbitControls.enabled = this.orbitControlsEnabledBeforeDrag_; - this.renderer.domElement.style.cursor = ""; - } - releaseActiveDragPointer_() { - if (this.activePointerId_ !== null && - typeof this.renderer.domElement.releasePointerCapture === "function" && - typeof this.renderer.domElement.hasPointerCapture === "function" && - this.renderer.domElement.hasPointerCapture(this.activePointerId_)) { - this.renderer.domElement.releasePointerCapture(this.activePointerId_); - } - this.activePointerId_ = null; - } - /** - * Points this.raycaster_ from the camera through the given pointer event's position, - * converted to Normalized Device Coordinates (NDC). - */ - updateRaycasterFromPointer_(event) { - if (!this.pointer_ || !this.raycaster_) - return; - const boundingRectangle = this.renderer.domElement.getBoundingClientRect(); - this.pointer_.x = - ((event.clientX - boundingRectangle.left) / boundingRectangle.width) * 2 - 1; - this.pointer_.y = - -((event.clientY - boundingRectangle.top) / boundingRectangle.height) * 2 + 1; - this.raycaster_.setFromCamera(this.pointer_, this.camera); - } - /** - * Raycasts from the given pointer event through the atoms in the scene. - * @returns the frontmost hit atom mesh, or null if none was hit. - */ - getAtomAtPointer(event) { - if (!this.pointer_ || !this.raycaster_) - return null; - this.updateRaycasterFromPointer_(event); - const intersections = this.raycaster_.intersectObjects(this.collectSelectableAtoms()); - return intersections.length > 0 ? intersections[0].object : null; - } - /** - * Raycasts from the given pointer event and intersects it with the given plane. - * @returns the intersection point, or null if the ray is parallel to the plane. - */ - getPointerPlaneIntersection(event, plane) { - if (!this.pointer_ || !this.raycaster_) - return null; - this.updateRaycasterFromPointer_(event); - const target = new THREE.Vector3(); - return this.raycaster_.ray.intersectPlane(plane, target) ? target : null; - } - /** - * Handles a plain click (no drag) on the canvas to select an atom mesh and attach the - * transform gizmo, or to deselect when clicking empty space. Shift+click adds to the - * current selection, Ctrl/Cmd+click toggles a single atom in/out of it; Shift/Ctrl+click - * on empty space is a no-op (the current selection is left alone rather than being - * surprisingly cleared mid-multi-select). - * @param {PointerEvent} event - Native browser pointer event. - */ - handlePointerDown(event) { - if (!this.isEditModeEnabled_) - return; - // Block new selection raycasts if the user is already interacting with the TransformControls handles - if (this.transformControls_ && this.transformControls_.dragging) - return; - const selectedAtomMesh = this.getAtomAtPointer(event); - if (selectedAtomMesh) { - const isAlreadySelected = this.selectedMeshes_.includes(selectedAtomMesh); - let nextSelection; - if (event.ctrlKey || event.metaKey) { - nextSelection = isAlreadySelected - ? this.selectedMeshes_.filter((mesh) => mesh !== selectedAtomMesh) - : [...this.selectedMeshes_, selectedAtomMesh]; - } - else if (event.shiftKey) { - nextSelection = isAlreadySelected - ? this.selectedMeshes_ - : [...this.selectedMeshes_, selectedAtomMesh]; - } - else { - nextSelection = [selectedAtomMesh]; - } - this.setSelectedAtomMeshes(nextSelection); - if (this.settings.onSelectionChanged) { - this.settings.onSelectionChanged(nextSelection.map((mesh) => mesh.userData.atomicIndex)); - } - this.render(); - } - else if (!event.shiftKey && !event.ctrlKey && !event.metaKey) { - // Clicking on empty space (with no modifier) detaches the transform controls gizmo - this.clearSelection(); - if (this.settings.onSelectionChanged) { - this.settings.onSelectionChanged([]); - } - this.render(); - } - } - /** - * Highlights the given atom mesh and attaches the transform gizmo to it. Thin - * single-atom wrapper over setSelectedAtomMeshes, kept for callers/tests that only ever - * deal with one atom at a time. - */ - setSelectedAtomMesh(atomMesh) { - this.setSelectedAtomMeshes([atomMesh]); - } - /** - * Sets the full multi-atom selection: highlights every selected atom via the halo pool, - * and attaches the gizmo directly to the sole atom (single selection) or to a pivot at - * the selection's centroid (2+ atoms), so a group drag moves every selected atom rigidly - * (see beginAtomDrag_/the TransformControls "change"/"mouseUp" listeners). - */ - setSelectedAtomMeshes(meshes) { - this.selectedMeshes_ = meshes; - this.selectedMesh_ = meshes.length > 0 ? meshes[meshes.length - 1] : null; - if (meshes.length > 0) { - this.lastSelectedAtomicIndices_ = meshes.map((mesh) => mesh.userData.atomicIndex); - } - this.syncHighlightPoolTo_(meshes); - if (meshes.length === 0) { - if (this.transformControls_) - this.transformControls_.detach(); - } - else if (meshes.length === 1) { - if (this.transformControls_) - this.transformControls_.attach(meshes[0]); - } - else { - this.attachPivotToSelection_(); - } - } - /** - * Clears the current atom selection, removes its highlight(s), and detaches the gizmo. - * @param {boolean} forgetLastSelection - Also forget the remembered indices used to - * restore selection when edit mode is re-enabled (see enableEditMode). Defaults to true - * for an explicit user deselect; enableEditMode(false) passes false so toggling edit - * mode off and back on preserves the selection (R12). - */ - clearSelection(forgetLastSelection = true) { - this.setSelectedAtomMeshes([]); - if (forgetLastSelection) - this.lastSelectedAtomicIndices_ = null; - } - /** - * Single-atom-named alias for clearSelection, kept for existing callers/tests. - */ - clearSelectedAtom(forgetLastSelection = true) { - this.clearSelection(forgetLastSelection); - } - /** - * Re-attaches the selection/gizmo to the atom meshes with the given atomicIndices. - * Used after a scene rebuild, since rebuilding replaces every atom mesh instance and - * would otherwise leave the gizmo attached to mesh(es) that no longer exist in the scene. - * Clears the selection if none of the given indices match any atom any more (e.g. it was - * deleted). Indices with no match are silently dropped rather than clearing everything, - * so removing one atom out of a multi-selection keeps the rest selected. - * - * The sole place that notifies onSelectionChanged for a reselect, and only when the - * result actually differs from what was selected going in - critically, this covers - * rebuildScene() (wave.js), which calls this on every rebuild (including one triggered by - * undo/redo, which can shift or remove indices out from under an unrelated rebuild) but - * has no way to notify the host itself. Without this, undo/redo across an add/remove - * boundary left the host's selection state pointing at an atomicIndex that had silently - * stopped existing on the wave side. Callers that already know their own new selection - * (addAtom, cloneSelectedAtoms, enableEditMode's restore) used to fire this explicitly - * too; that's now redundant and has been removed to keep this the single source of truth. - */ - reselectAtomsByIndices(atomicIndices) { - if (!atomicIndices || atomicIndices.length === 0) - return; - const validIndices = atomicIndices.filter((index) => index !== null && index !== undefined); - if (validIndices.length === 0) - return; - const previousIndices = this.selectedMeshes_.map((mesh) => mesh.userData.atomicIndex); - const matches = this.collectSelectableAtoms().filter((atom) => validIndices.includes(atom.userData.atomicIndex)); - this.setSelectedAtomMeshes(matches); - const resultIndices = matches.map((atom) => atom.userData.atomicIndex); - const isUnchanged = resultIndices.length === previousIndices.length && - resultIndices.every((index) => previousIndices.includes(index)); - if (!isUnchanged && this.settings.onSelectionChanged) { - this.settings.onSelectionChanged(resultIndices); - } - } - /** - * Single-index-named alias for reselectAtomsByIndices, kept for existing callers/tests. - */ - reselectAtomByIndex(atomicIndex) { - this.reselectAtomsByIndices([atomicIndex]); - } - /** - * Enables or disables edit mode interactions and controls visibility. Disabling - * preserves the selection's indices (see clearSelection) so re-enabling edit mode - * restores it (R12) rather than always starting deselected. While enabled, remaps the - * OrbitControls left mouse button off (freeing it for marquee-select on empty space) and - * moves camera rotation onto the right mouse button (decision D-4); the defaults are - * restored on disable. - * - * Touch gets the same treatment for the same reason (U-13). OrbitControls' default - * `touches.ONE` is ROTATE, which is the finger the editor needs for selecting, dragging an - * atom and marquee-selecting - so while edit mode is on, one finger belongs to the editor and - * the camera moves to two fingers (DOLLY_ROTATE: pinch to zoom, twist to orbit). There is no - * right button to move it to. - * @param {boolean} enabled - True to enable, false to disable. - */ - enableEditMode(enabled) { - this.isEditModeEnabled_ = enabled; - if (this.orbitControls) { - if (enabled) { - this.orbitControlsDefaultMouseButtons_ = { ...this.orbitControls.mouseButtons }; - this.orbitControls.mouseButtons = { - ...this.orbitControls.mouseButtons, - LEFT: null, - RIGHT: THREE.MOUSE.ROTATE, - }; - this.orbitControlsDefaultTouches_ = { ...this.orbitControls.touches }; - this.orbitControls.touches = { - // OrbitControls switches on `touches.ONE` and falls through to STATE.NONE - // for anything it does not recognise, which is how a one-finger gesture is - // handed to the editor - the same "no camera on this input" intent as - // LEFT: null above, expressed the only way the touch path allows. - ONE: ONE_FINGER_RESERVED_FOR_EDITING, - TWO: THREE.TOUCH.DOLLY_ROTATE, - }; - } - else { - if (this.orbitControlsDefaultMouseButtons_) { - this.orbitControls.mouseButtons = this.orbitControlsDefaultMouseButtons_; - this.orbitControlsDefaultMouseButtons_ = null; - } - if (this.orbitControlsDefaultTouches_) { - this.orbitControls.touches = this.orbitControlsDefaultTouches_; - this.orbitControlsDefaultTouches_ = null; - } - } - } - if (!enabled) { - if (this.isDraggingAtom_) - this.cancelAtomDrag_(); - if (this.isMarqueeSelecting_ || this.marqueeStartScreen_) { - this.hideMarqueeOverlay_(); - this.marqueeStartScreen_ = null; - this.isMarqueeSelecting_ = false; - } - this.hoveredMesh_ = null; - this.updateHighlightMesh_(this.hoverHighlightMesh_, null); - this.renderer.domElement.style.cursor = ""; - if (this.transformControls_) { - this.clearSelection(false); - if (this.settings.onSelectionChanged) { - this.settings.onSelectionChanged([]); - } - this.render(); - } - } - else if (this.lastSelectedAtomicIndices_) { - // reselectAtomsByIndices fires onSelectionChanged itself when this actually - // restores a selection (selectedMeshes_ is empty at this point, from the disable - // branch's clearSelection(false) above, so the restore always counts as changed). - this.reselectAtomsByIndices(this.lastSelectedAtomicIndices_); - } - } - /** - * Shorthand for disabling edit mode. - */ - disableEditMode() { - this.enableEditMode(false); - } - /** - * Updates the TransformControls mode (translate, rotate). - * @param {string} mode - Mode name ("translate" or "rotate"). - */ - setTransformMode(mode) { - if (this.transformControls_) { - this.transformControls_.setMode(mode); - } - } - /** - * Re-points the edit gizmo at the newly active camera so dragging keeps working - * after the user switches between perspective and orthographic projection. - */ - toggleOrthographicCamera() { - super.toggleOrthographicCamera(); - if (this.transformControls_) { - this.transformControls_.camera = this.camera; - } - } - /** - * Applies a basis mutation to a clone of the wave's own current structure, preserving - * its lattice, units, metadata, labels, and constraints exactly - only the fields the - * callback actually touches change. This replaces the old approach of re-deriving the - * whole material from the live Three.js scene on every edit (ThreeDSceneDataToMaterial), - * which was a lossy round trip: it reintroduced the lattice to only ~1e-7 precision - * (causing spurious camera resets, D1) and could pick up bond/boundary/repetition meshes - * as phantom atoms (D5) - a class of bug that a delta applied to the known material - * cannot reintroduce, because the lattice and every untouched atom are never recomputed. - */ - applyBasisDelta_(mutateBasis) { - const updatedMaterial = this.structure.clone(); - const basis = updatedMaterial.getBasis(); - mutateBasis(basis); - updatedMaterial.setBasis(basis.toJSON()); - return updatedMaterial; - } - /** - * Adds an atom to the structure and triggers scene reconstruction. The new atom is - * auto-selected once the scene has rebuilt around the updated material. - * @param {string} elementName - Chemical element symbol (e.g. "Si"). - * @param {Array} cartesianCoordinates - [x, y, z] position in Cartesian space. - */ - addAtom(elementName, cartesianCoordinates) { - const newMaterial = this.applyBasisDelta_((basis) => { - const coordinate = basis.isInCartesianUnits - ? cartesianCoordinates - : basis.cell.convertPointToCrystal(cartesianCoordinates); - basis.addAtom({ element: elementName, coordinate }); - }); - const newIndex = newMaterial.getBasis().elements.length - 1; - this.setStructure(newMaterial); - this.structureGroup.name = newMaterial.name || newMaterial.formula; - this.rebuildScene(); - // reselectAtomByIndex fires onSelectionChanged itself (the new atom always differs - // from whatever was selected before Add Atom ran). - this.reselectAtomByIndex(newIndex); - if (this.settings.onStructureModified) { - this.settings.onStructureModified(newMaterial, "add"); - } - } - /** - * Removes the currently selected atom(s) from the structure - the whole multi-selection - * if 2+ atoms are selected (as one commit, one history entry), or the single selected - * atom otherwise. - */ - removeSelectedAtom() { - if (this.selectedMeshes_.length > 1) { - this.removeSelectedAtoms_(); - return; - } - if (!this.selectedMesh_) - return; - const targetIndex = this.selectedMesh_.userData.atomicIndex; - const newMaterial = this.applyBasisDelta_((basis) => { - var _a, _b, _c; - const removedId = (_a = basis.elements[targetIndex]) === null || _a === void 0 ? void 0 : _a.id; - basis.elements = basis.elements.filter((_element, index) => index !== targetIndex); - basis.coordinates = basis.coordinates.filter((_coordinate, index) => index !== targetIndex); - if ((_b = basis.labels) === null || _b === void 0 ? void 0 : _b.length) { - basis.labels = basis.labels.filter((label) => label.id !== removedId); - } - if ((_c = basis.constraints) === null || _c === void 0 ? void 0 : _c.length) { - basis.constraints = basis.constraints.filter((constraint) => constraint.id !== removedId); - } - }); - this.clearSelection(); - if (this.settings.onSelectionChanged) { - this.settings.onSelectionChanged([]); - } - this.setStructure(newMaterial); - this.structureGroup.name = newMaterial.name || newMaterial.formula; - this.rebuildScene(); - if (this.settings.onStructureModified) { - this.settings.onStructureModified(newMaterial, "remove"); - } - } - /** - * Removes every currently multi-selected atom as a single delta/commit. - */ - removeSelectedAtoms_() { - const targetIndices = new Set(this.selectedMeshes_.map((mesh) => mesh.userData.atomicIndex)); - if (targetIndices.size === 0) - return; - const newMaterial = this.applyBasisDelta_((basis) => { - var _a, _b; - const removedIds = new Set([...targetIndices] - .map((index) => { var _a; return (_a = basis.elements[index]) === null || _a === void 0 ? void 0 : _a.id; }) - .filter((id) => id !== undefined)); - basis.elements = basis.elements.filter((_element, index) => !targetIndices.has(index)); - basis.coordinates = basis.coordinates.filter((_coordinate, index) => !targetIndices.has(index)); - if ((_a = basis.labels) === null || _a === void 0 ? void 0 : _a.length) { - basis.labels = basis.labels.filter((label) => !removedIds.has(label.id)); - } - if ((_b = basis.constraints) === null || _b === void 0 ? void 0 : _b.length) { - basis.constraints = basis.constraints.filter((constraint) => !removedIds.has(constraint.id)); - } - }); - this.clearSelection(); - if (this.settings.onSelectionChanged) { - this.settings.onSelectionChanged([]); - } - this.setStructure(newMaterial); - this.structureGroup.name = newMaterial.name || newMaterial.formula; - this.rebuildScene(); - if (this.settings.onStructureModified) { - this.settings.onStructureModified(newMaterial, "remove"); - } - } - /** - * Duplicates every selected atom at a small offset from its source, preserving element - * and (for the group case) relative positions, as a single commit. The clones become the - * new selection, matching Add Atom's auto-select behavior. Old-editor parity: its "clone - * existing" was one of only two ways to add an atom of a specific element, the other - * being a plain add-then-rename (see changeAtomElement / D-9). - * - * Shares Add Atom's occupied-site guard (D22): the offset nudges further along the same - * diagonal until clear of every existing atom (and of any sibling clone already placed - * earlier in this same call, so cloning several selected atoms at once can't collide with - * each other either), so cloning the same atom repeatedly doesn't silently stack - * coincident duplicates. - */ - cloneSelectedAtoms() { - if (this.selectedMeshes_.length === 0) - return; - const OCCUPIED_TOLERANCE = 0.5; // Å; below any realistic bond length - const OFFSET_STEP = new THREE.Vector3(0.3, 0.3, 0.3); - const MAX_OFFSET_ATTEMPTS = 10; - const { elements } = this.structure.basis; - // this.basis (AtomsMixin) is kept in Cartesian units from setStructure() onward - - // exactly the space CLONE_OFFSET/OCCUPIED_TOLERANCE are defined in. - const existingPositions = this.basis.coordinatesAsArray.map((coordinate) => new THREE.Vector3(...coordinate)); - const placedPositions = []; - const isOccupied = (position) => existingPositions - .concat(placedPositions) - .some((existing) => existing.distanceTo(position) < OCCUPIED_TOLERANCE); - const sourceAtoms = this.selectedMeshes_.map((mesh) => { - var _a; - const elementEntry = elements[mesh.userData.atomicIndex]; - const element = typeof elementEntry === "string" ? elementEntry : (_a = elementEntry === null || elementEntry === void 0 ? void 0 : elementEntry.value) !== null && _a !== void 0 ? _a : "Si"; - let attempts = 1; - let candidate = mesh.position.clone().add(OFFSET_STEP); - while (isOccupied(candidate) && attempts < MAX_OFFSET_ATTEMPTS) { - attempts += 1; - candidate = mesh.position - .clone() - .add(OFFSET_STEP.clone().multiplyScalar(attempts)); - } - placedPositions.push(candidate); - return { element, position: candidate }; - }); - const newMaterial = this.applyBasisDelta_((basis) => { - sourceAtoms.forEach(({ element, position }) => { - const coordinate = basis.isInCartesianUnits - ? position.toArray() - : basis.cell.convertPointToCrystal(position.toArray()); - basis.addAtom({ element, coordinate }); - }); - }); - const startIndex = newMaterial.getBasis().elements.length - sourceAtoms.length; - const newIndices = sourceAtoms.map((_atom, index) => startIndex + index); - this.setStructure(newMaterial); - this.rebuildScene(); - // reselectAtomsByIndices fires onSelectionChanged itself (the clones always differ - // from whatever was selected going in). - this.reselectAtomsByIndices(newIndices); - if (this.settings.onStructureModified) { - this.settings.onStructureModified(newMaterial, "clone"); - } - } - /** - * Frames the camera on the current selection's bounding sphere, preserving the current - * viewing angle (only re-targeting and re-distancing, not resetting to a canonical - * axis-aligned view like adjustCamerasAndOrbitControlsToCell does for the whole cell). - * The one camera move an edit-mode interaction is allowed to make, since it's a direct, - * explicit user action (F key) rather than a side effect of an edit (US-12). - */ - focusCameraOnSelection() { - if (this.selectedMeshes_.length === 0 || !this.orbitControls) - return; - const boundingBox = new THREE.Box3(); - this.selectedMeshes_.forEach((mesh) => boundingBox.expandByPoint(mesh.position)); - const center = boundingBox.getCenter(new THREE.Vector3()); - const extent = boundingBox.getSize(new THREE.Vector3()).length(); - const MIN_FOCUS_RADIUS = 2; // Å; keeps a single-atom focus from zooming in absurdly close - const radius = Math.max(extent / 2, MIN_FOCUS_RADIUS); - const previousTarget = this.orbitControls.target.clone(); - const viewDirection = this.camera.position.clone().sub(previousTarget); - if (viewDirection.lengthSq() < 1e-9) - viewDirection.set(0, 0, 1); - viewDirection.normalize(); - if (this.camera.isOrthographicCamera) { - this.setOrthographicCameraFrustum(this.PADDING_RATIO * radius * 2); - this.camera.position.copy(center.clone().add(viewDirection.multiplyScalar(Math.max(radius * 4, 10)))); - } - else { - const fovInRadians = (this.camera.fov * Math.PI) / 180; - const distance = (this.PADDING_RATIO * radius * 2) / Math.tan(fovInRadians / 2); - this.camera.position.copy(center.clone().add(viewDirection.multiplyScalar(distance))); - } - this.orbitControls.target.copy(center); - this.camera.lookAt(center); - this.orbitControls.update(); - this.render(); - } - /** - * Commits a single moved atom (from either a direct drag or a gizmo drag) as one delta - * applied to the current material, then rebuilds the scene around it. rebuildScene() - * itself preserves the selection/gizmo across the rebuild when in edit mode (see - * wave.js), so no explicit reselect is needed here for the move case. - */ - commitMovedAtom_(atomicIndex, cartesianPosition, source) { - this.commitMovedAtoms_([{ atomicIndex, position: cartesianPosition }], source); - } - /** - * Commits any number of moved atoms as a single delta/commit (one history entry) applied - * to the current material, then rebuilds the scene around it. `source` (spec Sec6.2's - * onEditCommit contract - "drag" for a direct body-drag, "gizmo" for a TransformControls - * drag) is forwarded to onStructureModified so ThreeDEditor.jsx can pass it on to a host's - * onEditCommit without having to re-infer which gesture produced this commit. - */ - commitMovedAtoms_(moves, source) { - if (!moves.length) - return; - const newMaterial = this.applyBasisDelta_((basis) => { - const { coordinates } = basis; - moves.forEach(({ atomicIndex, position }) => { - if (!coordinates[atomicIndex]) - return; - const value = basis.isInCartesianUnits - ? position.toArray() - : basis.cell.convertPointToCrystal(position.toArray()); - coordinates[atomicIndex] = { - ...coordinates[atomicIndex], - value, - }; - }); - basis.coordinates = coordinates; - }); - this.setStructure(newMaterial); - this.rebuildScene(); - if (this.settings.onStructureModified) { - this.settings.onStructureModified(newMaterial, source); - } - } - /** - * Lifecycle hook to dispose event listeners and objects on visualizer destruction. - */ - dispose() { - if (this.renderer && this.renderer.domElement) { - if (this.handlePointerDownCapture_) { - this.renderer.domElement.removeEventListener("pointerdown", this.handlePointerDownCapture_); - } - if (this.handlePointerMoveCapture_) { - this.renderer.domElement.removeEventListener("pointermove", this.handlePointerMoveCapture_); - } - if (this.handlePointerUpCapture_) { - this.renderer.domElement.removeEventListener("pointerup", this.handlePointerUpCapture_); - } - if (this.handlePointerCancelCapture_) { - this.renderer.domElement.removeEventListener("pointercancel", this.handlePointerCancelCapture_); - } - } - if (this.handleEditModeKeyDown_) { - document.removeEventListener("keydown", this.handleEditModeKeyDown_); - } - if (this.transformControls_) { - this.transformControls_.dispose(); - } - [...this.selectionHighlightPool_, this.hoverHighlightMesh_].forEach((haloMesh) => { - if (!haloMesh) - return; - this.scene.remove(haloMesh); - haloMesh.geometry.dispose(); - haloMesh.material.dispose(); - }); - if (this.marqueeOverlayElement_) { - this.marqueeOverlayElement_.remove(); - this.marqueeOverlayElement_ = null; - } - if (super.dispose) - super.dispose(); - } -}; diff --git a/dist/mixins/labels/all.d.ts b/dist/mixins/labels/all.d.ts deleted file mode 100644 index 51bedbfe..00000000 --- a/dist/mixins/labels/all.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { CoordinateLabelsManager } from "./coordinate"; -import { ElementLabelsManager } from "./element"; -export declare const AllLabelsMixin: (superclass: any) => { - new (): { - [x: string]: any; - labelManagers: (ElementLabelsManager | CoordinateLabelsManager)[]; - initializeLabelManagers(): void; - getLabelManagerByType(labelType: string): CoordinateLabelsManager | ElementLabelsManager | undefined; - createAllLabels(): void; - adjustAllLabelsToCameraPosition(): void; - toggleLabelsVisibilityByType(labelType: string): void; - areLabelsVisibleByType(labelType: string): boolean | undefined; - }; - [x: string]: any; -}; diff --git a/dist/mixins/labels/all.js b/dist/mixins/labels/all.js deleted file mode 100644 index d5a2ecb5..00000000 --- a/dist/mixins/labels/all.js +++ /dev/null @@ -1,39 +0,0 @@ -import { CoordinateLabelsManager } from "./coordinate"; -import { ElementLabelsManager } from "./element"; -/* - * Base mixin containing generic logic for dealing with labels. - * Provides core functionality for creating and managing text labels in 3D space. - */ -export const AllLabelsMixin = (superclass) => class extends superclass { - constructor() { - super(...arguments); - this.labelManagers = []; - } - initializeLabelManagers() { - const elementLabelsManager = new ElementLabelsManager(this.structureGroup, this.camera, this); - const coordinateLabelsManager = new CoordinateLabelsManager(this.structureGroup, this.camera, this); - this.labelManagers.push(elementLabelsManager, coordinateLabelsManager); - } - getLabelManagerByType(labelType) { - return this.labelManagers.find((manager) => manager.labelType === labelType); - } - createAllLabels() { - this.labelManagers.forEach((manager) => manager.createLabels()); - } - adjustAllLabelsToCameraPosition() { - this.labelManagers.forEach((manager) => manager.adjustLabelsToCameraPosition()); - } - toggleLabelsVisibilityByType(labelType) { - if (!this.labelManagers.length) { - this.initializeLabelManagers(); - } - const labelManager = this.getLabelManagerByType(labelType); - labelManager === null || labelManager === void 0 ? void 0 : labelManager.toggleVisibility(); - // this.createAllLabels(); - // this.rebuildScene(); - } - areLabelsVisibleByType(labelType) { - const labelManager = this.getLabelManagerByType(labelType); - return labelManager === null || labelManager === void 0 ? void 0 : labelManager.isVisible; - } -}; diff --git a/dist/mixins/labels/angle.d.ts b/dist/mixins/labels/angle.d.ts deleted file mode 100644 index 6a2c2093..00000000 --- a/dist/mixins/labels/angle.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as THREE from "three"; -import { BaseLabelsManager } from "./base"; -export declare class AngleLabelsManager extends BaseLabelsManager { - labelType: string; - config: { - areSpritesUsed: boolean; - fontFace: string; - fontSize: number; - fontWeight: string; - scale: number; - scaleWidth: number; - scaleHeight: number; - offsetVector: number[]; - textParameters: { - fillStyle: string; - strokeStyle: string; - lineWidth: number; - textAlign: string; - textBaseline: string; - }; - }; - getLabelTextFromLabeledObject(object: THREE.Object3D): string; -} diff --git a/dist/mixins/labels/angle.js b/dist/mixins/labels/angle.js deleted file mode 100644 index 525d7fce..00000000 --- a/dist/mixins/labels/angle.js +++ /dev/null @@ -1,15 +0,0 @@ -import settings from "../../settings"; -import { BaseLabelsManager } from "./base"; -export class AngleLabelsManager extends BaseLabelsManager { - constructor() { - super(...arguments); - this.labelType = "angle"; - this.config = settings.angleLabelsConfig; - } - getLabelTextFromLabeledObject(object) { - if (object.userData.angle !== undefined) { - return `${object.userData.angle.toFixed(2)}°`; - } - return ""; - } -} diff --git a/dist/mixins/labels/as_points.d.ts b/dist/mixins/labels/as_points.d.ts deleted file mode 100644 index becaeb88..00000000 --- a/dist/mixins/labels/as_points.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as THREE from "three"; -import { BaseLabelsManager } from "./base"; -export declare abstract class BaseLabelsAsPointsManager extends BaseLabelsManager { - /** - * Creates a label as points for efficient rendering of many labels - * @param {String} text - the text to be displayed - * @param {Array} positions - array of positions [x1,y1,z1,x2,y2,z2,...] - * @param {String} name - name for the points object - * @returns {THREE.Points} - */ - createLabelPoints(text: string, positions: number[], name: string): THREE.Points; - /** - * Creates and positions multiple labels efficiently using Three.Points - * For best performance when rendering many labels. - * @param {Object} verticesHashMap - Object with label names as keys and arrays of positions as values - * @param {Function} getNameForLabel - Function to get the name for a label - * @param {THREE.Group} targetGroup - Group to add the labels to - */ - createLabelsAsPoints(verticesHashMap: { - [key: string]: number[]; - }, getNameForLabel: (text: string) => string, targetGroup: THREE.Group): void; -} diff --git a/dist/mixins/labels/as_points.js b/dist/mixins/labels/as_points.js deleted file mode 100644 index 54ed53d1..00000000 --- a/dist/mixins/labels/as_points.js +++ /dev/null @@ -1,37 +0,0 @@ -import * as THREE from "three"; -import { BaseLabelsManager } from "./base"; -export class BaseLabelsAsPointsManager extends BaseLabelsManager { - /** - * Creates a label as points for efficient rendering of many labels - * @param {String} text - the text to be displayed - * @param {Array} positions - array of positions [x1,y1,z1,x2,y2,z2,...] - * @param {String} name - name for the points object - * @returns {THREE.Points} - */ - createLabelPoints(text, positions, name) { - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); - const material = new THREE.PointsMaterial(this.config); - const points = new THREE.Points(geometry, material); - points.name = name; - return points; - } - /** - * Creates and positions multiple labels efficiently using Three.Points - * For best performance when rendering many labels. - * @param {Object} verticesHashMap - Object with label names as keys and arrays of positions as values - * @param {Function} getNameForLabel - Function to get the name for a label - * @param {THREE.Group} targetGroup - Group to add the labels to - */ - createLabelsAsPoints(verticesHashMap, getNameForLabel, targetGroup) { - if (!targetGroup) { - console.warn("No target group provided for labels"); - return; - } - targetGroup.clear(); - Object.entries(verticesHashMap).forEach(([key, vertices]) => { - const points = this.createLabelPoints(key, vertices, getNameForLabel(key)); - targetGroup.add(points); - }); - } -} diff --git a/dist/mixins/labels/base.d.ts b/dist/mixins/labels/base.d.ts deleted file mode 100644 index 6178cf23..00000000 --- a/dist/mixins/labels/base.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as THREE from "three"; -import { BaseTHREEGroupManager } from "../base"; -import { VerticesHashMapHandler } from "../Hashmap"; -export type LabelsManagerConstructor = new (waveStructureGroup: THREE.Group, waveCamera: THREE.Camera, wave: any) => T; -export declare abstract class BaseLabelsManager extends BaseTHREEGroupManager { - labelType: string; - private THREETexturesCache; - abstract getLabelTextFromLabeledObject(object: THREE.Object3D): string; - getOffsetVectorMultiplierPerAtomName(atomName: string): any; - getVectorToCameraNormalized(position: THREE.Vector3, camera: THREE.Camera): THREE.Vector3; - getOffsetVector(position: THREE.Vector3, camera: THREE.Camera, offsetLength?: number): THREE.Vector3; - getNameForLabel(text: string): string; - createLabelTextTexture(text: string): THREE.Texture; - getLabelTextTexture(text: string): THREE.Texture; - /** - * Creates a sprite with a label text - */ - createLabelSprite(text: string, name: string): THREE.Sprite; - /** - * Creates and positions multiple labels as sprites - * More flexible but less performant than Points for many labels - */ - createLabelsAsSprites(verticesHashMap: VerticesHashMapHandler, threeGroup?: THREE.Group): void; - createLabels(atoms?: THREE.Object3D, threeGroup?: THREE.Group): void; - getLabelPositionWithOffset(position: THREE.Vector3, atomName: string): THREE.Vector3; - /** - * Adjusts labels to camera position. Applied to labels of a specific type. - */ - adjustLabelsToCameraPosition(): void; -} diff --git a/dist/mixins/labels/base.js b/dist/mixins/labels/base.js deleted file mode 100644 index f6f6e1e3..00000000 --- a/dist/mixins/labels/base.js +++ /dev/null @@ -1,117 +0,0 @@ -import * as THREE from "three"; -import settings from "../../settings"; -import { BaseTHREEGroupManager } from "../base"; -export class BaseLabelsManager extends BaseTHREEGroupManager { - constructor() { - super(...arguments); - this.labelType = ""; - this.THREETexturesCache = {}; - } - getOffsetVectorMultiplierPerAtomName(atomName) { - if (!atomName) - return 1; - return this.wave.getAtomRadiusByElement(atomName.split("-")[0]); - } - getVectorToCameraNormalized(position, camera) { - const vectorToCamera = new THREE.Vector3().subVectors(camera.position, position); - vectorToCamera.normalize(); - return vectorToCamera; - } - getOffsetVector(position, camera, offsetLength = 1) { - const vectorToCamera = this.getVectorToCameraNormalized(position, camera); - const constantOffset = new THREE.Vector3(...(this.config.offsetVector || [0, 0, 0])); - vectorToCamera.multiplyScalar(offsetLength); - vectorToCamera.add(constantOffset); - return vectorToCamera; - } - getNameForLabel(text) { - return `${this.labelType}-label-for-${text}`; - } - createLabelTextTexture(text) { - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d") || new CanvasRenderingContext2D(); - const canvasWidth = 256 * this.config.scaleWidth; - const canvasHeight = 256 * this.config.scaleHeight; - Object.assign(canvas, { - width: canvasWidth, - height: canvasHeight, - }); - Object.assign(context, { - font: `${this.config.fontWeight} ${this.config.fontSize}px ${this.config.fontFace}`, - ...this.config.textParameters, - }); - context.fillText(text, canvasWidth / 2, canvasHeight / 2); - context.strokeText(text, canvasWidth / 2, canvasHeight / 2); - const texture = new THREE.Texture(canvas); - texture.needsUpdate = true; - return texture; - } - getLabelTextTexture(text) { - if (this.THREETexturesCache[text]) - return this.THREETexturesCache[text]; - const texture = this.createLabelTextTexture(text); - this.THREETexturesCache[text] = texture; - return texture; - } - /** - * Creates a sprite with a label text - */ - createLabelSprite(text, name) { - const spriteMaterial = new THREE.SpriteMaterial({ - map: this.getLabelTextTexture(text), - ...settings.labelSpriteConfig, - }); - const sprite = new THREE.Sprite(spriteMaterial); - sprite.name = name; - // TODO: remove - scale this inside createLabelTextTexture - const scaleX = this.config.scaleWidth || this.config.scale || 1; - const scaleY = this.config.scaleHeight || this.config.scale || 1; - sprite.scale.set(scaleX, scaleY, 1); - return sprite; - } - /** - * Creates and positions multiple labels as sprites - * More flexible but less performant than Points for many labels - */ - createLabelsAsSprites(verticesHashMap, threeGroup = this.THREEGroup) { - threeGroup.clear(); - verticesHashMap.iterateCoordinates((key, coordinateAsArray) => { - const position = new THREE.Vector3().fromArray(coordinateAsArray); - const name = this.getNameForLabel(key); - const labelSprite = this.createLabelSprite(key, name); - labelSprite.userData = { position }; - labelSprite.position.copy(this.getLabelPositionWithOffset(position, key)); - threeGroup.add(labelSprite); - }); - } - createLabels(atoms, threeGroup = this.THREEGroup) { - const verticesHashMap = this.wave.createAtomVerticesHashMap(this.getLabelTextFromLabeledObject, atoms); - if (this.config.areSpritesUsed) { - this.createLabelsAsSprites(verticesHashMap, threeGroup); - } - else { - throw new Error("Labels as points are not implemented yet"); - } - // Only add to structureGroup if not already added - if (!this.waveStructureGroup.children.includes(threeGroup)) { - this.waveStructureGroup.add(threeGroup); - } - } - getLabelPositionWithOffset(position, atomName) { - const offsetLength = this.getOffsetVectorMultiplierPerAtomName(atomName); - const offsetVector = this.getOffsetVector(position, this.waveCamera, offsetLength); - return position.clone().add(offsetVector); - } - /** - * Adjusts labels to camera position. Applied to labels of a specific type. - */ - adjustLabelsToCameraPosition() { - if (!this.isVisible || !this.config.areSpritesUsed) - return; - this.THREEGroup.children.forEach((label) => { - const { position, atomName } = label.userData; - label.position.copy(this.getLabelPositionWithOffset(position, atomName)); - label.lookAt(this.waveCamera.position); - }); - } -} diff --git a/dist/mixins/labels/coordinate.d.ts b/dist/mixins/labels/coordinate.d.ts deleted file mode 100644 index 392d4777..00000000 --- a/dist/mixins/labels/coordinate.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as THREE from "three"; -import { BaseLabelsManager } from "./base"; -export declare class CoordinateLabelsManager extends BaseLabelsManager { - labelType: string; - isVisible: boolean; - config: { - areSpritesUsed: boolean; - fontFace: string; - fontSize: number; - fontWeight: string; - scale: number; - scaleWidth: number; - scaleHeight: number; - offsetVector: number[]; - textParameters: { - fillStyle: string; - strokeStyle: string; - lineWidth: number; - textAlign: string; - textBaseline: string; - }; - }; - constructor(waveStructureGroup: THREE.Group, waveCamera: THREE.Camera, wave: any, groupName?: string); - getLabelTextFromLabeledObject(atom: THREE.Object3D): string; -} diff --git a/dist/mixins/labels/coordinate.js b/dist/mixins/labels/coordinate.js deleted file mode 100644 index 1daca3c6..00000000 --- a/dist/mixins/labels/coordinate.js +++ /dev/null @@ -1,19 +0,0 @@ -import { LABEL_TYPES } from "../../enums"; -import settings from "../../settings"; -import { getArrayFromVector } from "../utils_three"; -import { BaseLabelsManager } from "./base"; -// @ts-ignore -export class CoordinateLabelsManager extends BaseLabelsManager { - constructor(waveStructureGroup, waveCamera, wave, groupName = LABEL_TYPES.COORDINATE) { - super(waveStructureGroup, waveCamera, wave, groupName); - this.labelType = LABEL_TYPES.COORDINATE; - this.isVisible = false; - this.config = settings.coordinateLabelsConfig; - } - getLabelTextFromLabeledObject(atom) { - const separator = " "; - const precision = settings.roundPrecision; - const vectorAsArray = getArrayFromVector(atom.position); - return vectorAsArray.map((coord) => coord.toFixed(precision)).join(separator); - } -} diff --git a/dist/mixins/labels/distance.d.ts b/dist/mixins/labels/distance.d.ts deleted file mode 100644 index f31639e6..00000000 --- a/dist/mixins/labels/distance.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as THREE from "three"; -import { BaseLabelsManager } from "./base"; -export declare class DistanceLabelsManager extends BaseLabelsManager { - labelType: string; - isVisible: boolean; - config: { - areSpritesUsed: boolean; - fontFace: string; - fontSize: number; - fontWeight: string; - scale: number; - scaleWidth: number; - scaleHeight: number; - offsetVector: number[]; - textParameters: { - fillStyle: string; - strokeStyle: string; - lineWidth: number; - textAlign: string; - textBaseline: string; - }; - }; - constructor(waveStructureGroup: THREE.Group, waveCamera: THREE.Camera, wave: any, groupName?: string); - getLabelTextFromLabeledObject(object: THREE.Object3D): string; -} diff --git a/dist/mixins/labels/distance.js b/dist/mixins/labels/distance.js deleted file mode 100644 index 10e0a5ec..00000000 --- a/dist/mixins/labels/distance.js +++ /dev/null @@ -1,18 +0,0 @@ -import { LABEL_TYPES } from "../../enums"; -import settings from "../../settings"; -import { BaseLabelsManager } from "./base"; -export class DistanceLabelsManager extends BaseLabelsManager { - constructor(waveStructureGroup, waveCamera, wave, groupName = LABEL_TYPES.DISTANCE) { - super(waveStructureGroup, waveCamera, wave, groupName); - this.labelType = LABEL_TYPES.DISTANCE; - this.isVisible = false; - this.config = settings.distanceLabelsConfig; - } - getLabelTextFromLabeledObject(object) { - if (object.userData.distance !== undefined) { - const { distance } = object.userData; - return `${distance.toFixed(settings.roundPrecision)} Å`; - } - return ""; - } -} diff --git a/dist/mixins/labels/element.d.ts b/dist/mixins/labels/element.d.ts deleted file mode 100644 index 06dff9c2..00000000 --- a/dist/mixins/labels/element.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as THREE from "three"; -import { BaseLabelsManager } from "./base"; -export declare class ElementLabelsManager extends BaseLabelsManager { - labelType: string; - isVisible: boolean; - config: { - areSpritesUsed: boolean; - fontFace: string; - fontSize: number; - fontWeight: string; - scale: number; - scaleWidth: number; - scaleHeight: number; - textParameters: { - fillStyle: string; - strokeStyle: string; - lineWidth: number; - textAlign: string; - textBaseline: string; - }; - }; - constructor(waveStructureGroup: THREE.Group, waveCamera: THREE.Camera, wave: any); - getLabelTextFromLabeledObject(object: THREE.Object3D): any; -} diff --git a/dist/mixins/labels/element.js b/dist/mixins/labels/element.js deleted file mode 100644 index b9dd33da..00000000 --- a/dist/mixins/labels/element.js +++ /dev/null @@ -1,14 +0,0 @@ -import { LABEL_TYPES } from "../../enums"; -import settings from "../../settings"; -import { BaseLabelsManager } from "./base"; -export class ElementLabelsManager extends BaseLabelsManager { - constructor(waveStructureGroup, waveCamera, wave) { - super(waveStructureGroup, waveCamera, wave, LABEL_TYPES.ELEMENT); - this.labelType = LABEL_TYPES.ELEMENT; - this.isVisible = false; - this.config = settings.elementLabelsConfig; - } - getLabelTextFromLabeledObject(object) { - return object.userData.symbolWithLabel; - } -} diff --git a/dist/mixins/lines/LinesManager.d.ts b/dist/mixins/lines/LinesManager.d.ts deleted file mode 100644 index 27492bce..00000000 --- a/dist/mixins/lines/LinesManager.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as THREE from "three"; -import { BaseTHREEGroupManager } from "../base"; -export declare class LinesManager extends BaseTHREEGroupManager { - /** - * Creates a line between two atoms - */ - createLineBetweenAtoms(firstAtom: THREE.Object3D, secondAtom: THREE.Object3D): THREE.Line; - private createLineBetweenPoints; - createLinesFromAtomPairs(atomPairs: THREE.Object3D[][]): THREE.Line[]; - /** - * Creates an angle line connecting three atoms - */ - createAngleBetweenAtoms(firstAtom: THREE.Object3D, middleAtom: THREE.Object3D, lastAtom: THREE.Object3D): THREE.Line; - /** - * Gets the center position of a line - */ - getLineCenterPosition(line: THREE.Line): THREE.Vector3; - /** - * Removes a line from the group - */ - removeLine(line: THREE.Line): void; - /** - * Gets all lines in the group - */ - getLines(): THREE.Line[]; - setLineAsHovered(line: THREE.Line): void; - unsetLineAsHovered(line: THREE.Line): void; - setLineAsSelected(line: THREE.Line): void; - unsetLineAsSelected(line: THREE.Line): void; - deselectAllLines(): void; -} diff --git a/dist/mixins/lines/LinesManager.js b/dist/mixins/lines/LinesManager.js deleted file mode 100644 index 8cf3282e..00000000 --- a/dist/mixins/lines/LinesManager.js +++ /dev/null @@ -1,108 +0,0 @@ -import * as THREE from "three"; -import { ATOM_CONNECTION_LINE_NAME, COLORS } from "../../enums"; -import settings from "../../settings"; -import { BaseTHREEGroupManager } from "../base"; -import { calculateMidpoint, getAtomWorldPosition } from "../utils_three"; -export class LinesManager extends BaseTHREEGroupManager { - /** - * Creates a line between two atoms - */ - createLineBetweenAtoms(firstAtom, secondAtom) { - const firstAtomPoint = getAtomWorldPosition(firstAtom); - const secondAtomPoint = getAtomWorldPosition(secondAtom); - const line = this.createLineBetweenPoints(firstAtomPoint, secondAtomPoint); - line.userData.atomicIndices = [ - firstAtom.userData.atomicIndex, - secondAtom.userData.atomicIndex, - ]; - this.THREEGroup.add(line); - return line; - } - createLineBetweenPoints(start, end) { - const geometry = new THREE.BufferGeometry().setFromPoints([start, end]); - const material = new THREE.LineBasicMaterial({ color: settings.colors.amber }); - const line = new THREE.Line(geometry, material); - line.name = ATOM_CONNECTION_LINE_NAME; - return line; - } - createLinesFromAtomPairs(atomPairs) { - return atomPairs.map((pair) => { - const line = this.createLineBetweenAtoms(pair[0], pair[1]); - return line; - }); - } - /** - * Creates an angle line connecting three atoms - */ - createAngleBetweenAtoms(firstAtom, middleAtom, lastAtom) { - // Create a line geometry with three points - const firstPoint = getAtomWorldPosition(firstAtom); - const middlePoint = getAtomWorldPosition(middleAtom); - const lastPoint = getAtomWorldPosition(lastAtom); - const geometry = new THREE.BufferGeometry().setFromPoints([ - firstPoint, - middlePoint, - lastPoint, - ]); - const material = new THREE.LineBasicMaterial({ color: settings.colors.amber }); - const line = new THREE.Line(geometry, material); - line.name = ATOM_CONNECTION_LINE_NAME; - line.userData.atomicIndices = [ - firstAtom.userData.atomicIndex, - middleAtom.userData.atomicIndex, - lastAtom.userData.atomicIndex, - ]; - line.userData.isAngleLine = true; - this.THREEGroup.add(line); - return line; - } - /** - * Gets the center position of a line - */ - getLineCenterPosition(line) { - const geometry = line.geometry; - const positions = geometry.attributes.position.array; - const start = new THREE.Vector3(positions[0], positions[1], positions[2]); - const end = new THREE.Vector3(positions[3], positions[4], positions[5]); - return calculateMidpoint(start, end); - } - /** - * Removes a line from the group - */ - removeLine(line) { - this.THREEGroup.remove(line); - } - /** - * Gets all lines in the group - */ - getLines() { - return this.THREEGroup.children.filter((child) => child.type === "Line" && child.name === ATOM_CONNECTION_LINE_NAME); - } - setLineAsHovered(line) { - if (!line.userData.selected) { - line.material.color.set(COLORS.GREEN); - } - line.userData.hovered = true; - } - unsetLineAsHovered(line) { - if (!line.userData.selected) { - line.material.color.set(settings.colors.amber); - } - line.userData.hovered = false; - } - setLineAsSelected(line) { - this.deselectAllLines(); - line.userData.selected = true; - line.material.color.set(COLORS.GREEN); - } - unsetLineAsSelected(line) { - line.userData.selected = false; - line.material.color.set(line.userData.hovered ? COLORS.GREEN : settings.colors.amber); - } - deselectAllLines() { - this.getLines().forEach((line) => { - line.userData.selected = false; - line.material.color.set(line.userData.hovered ? COLORS.GREEN : settings.colors.amber); - }); - } -} diff --git a/dist/mixins/listeners/mixins.d.ts b/dist/mixins/listeners/mixins.d.ts deleted file mode 100644 index 4310c830..00000000 --- a/dist/mixins/listeners/mixins.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as THREE from "three"; -type Constructor = new (...args: any[]) => T; -export declare const ListenersMixin: (superclass: T) => { - new (...args: any[]): { - canvas: HTMLCanvasElement; - onClick(event: MouseEvent): void; - onPointerMove(event: MouseEvent): void; - destroyListeners(): void; - initListeners(updateState: (arg: object) => void): void; - }; -} & T; -export declare const RaycasterMixinWithListeners: (superclass: T) => { - new (...args: any[]): { - raycaster: THREE.Raycaster; - pointer: THREE.Vector2; - intersectedObject: THREE.Object3D | null; - initRaycaster(): void; - checkMouseCoordinates(event: MouseEvent, camera: THREE.Camera): void; - canvas: HTMLCanvasElement; - onClick(event: MouseEvent): void; - onPointerMove(event: MouseEvent): void; - destroyListeners(): void; - initListeners(updateState: (arg: object) => void): void; - }; -} & T; -export {}; diff --git a/dist/mixins/listeners/mixins.js b/dist/mixins/listeners/mixins.js deleted file mode 100644 index d0955753..00000000 --- a/dist/mixins/listeners/mixins.js +++ /dev/null @@ -1,44 +0,0 @@ -import * as THREE from "three"; -export const ListenersMixin = (superclass) => class extends superclass { - constructor() { - super(...arguments); - this.canvas = document.createElement("canvas"); - } - onClick(event) { - console.log("clicked"); - } - onPointerMove(event) { - console.log("moved"); - } - destroyListeners() { - this.canvas.removeEventListener("click", this.onClick); - this.canvas.removeEventListener("mousemove", this.onPointerMove); - } - initListeners(updateState) { - // @ts-ignore - const clickFunction = this.onClick.bind(this, updateState); - this.canvas.addEventListener("click", clickFunction); - this.canvas.addEventListener("mousemove", this.onPointerMove); - } -}; -export const RaycasterMixinWithListeners = (superclass) => class extends ListenersMixin(superclass) { - constructor() { - super(...arguments); - this.raycaster = new THREE.Raycaster(); - this.pointer = new THREE.Vector2(); - this.intersectedObject = null; - } - initRaycaster() { - this.raycaster = new THREE.Raycaster(); - // Assigned wholesale rather than mutating `.threshold` in place: Raycaster's - // `params.Line` is optional, so the in-place write was an unchecked dereference - // that only typechecked because @types/three was 33 minors ahead of the runtime. - this.raycaster.params.Line = { threshold: 0.1 }; - this.pointer = new THREE.Vector2(); - } - checkMouseCoordinates(event, camera) { - this.pointer.x = (event.offsetX / this.canvas.width) * 2 - 1; - this.pointer.y = -(event.offsetY / this.canvas.height) * 2 + 1; - this.raycaster.setFromCamera(this.pointer, camera); - } -}; diff --git a/dist/mixins/marquee_selection.d.ts b/dist/mixins/marquee_selection.d.ts deleted file mode 100644 index 3ff54c7f..00000000 --- a/dist/mixins/marquee_selection.d.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 6d4b6ea3..00000000 --- a/dist/mixins/marquee_selection.js +++ /dev/null @@ -1,138 +0,0 @@ -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/mixins/measurements/MeasurementSettingsHandler.d.ts b/dist/mixins/measurements/MeasurementSettingsHandler.d.ts deleted file mode 100644 index b4ca76c8..00000000 --- a/dist/mixins/measurements/MeasurementSettingsHandler.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { MEASUREMENT_MODES_ENUM } from "../../enums"; -export type MeasurementSettingsForType = { - isActive: boolean; - measurementType: MEASUREMENT_MODES_ENUM; - values: any[]; - /** Atom picks recorded so far for this mode. */ - selectedAtomsCount?: number; - /** Picks one measurement of this mode consumes - 2 for a distance, 3 for an angle. */ - atomsPerMeasurement?: number; -}; -export declare class MeasurementSettingsHandler { - measurementsSettings: MeasurementSettingsForType[]; - constructor(measurementsSettings: MeasurementSettingsForType[]); - isMeasurementActiveByType(measurementType: MEASUREMENT_MODES_ENUM): boolean; - updateMeasurementSettingsByType(newSettings: MeasurementSettingsForType): void; - /** - * The settings entry for whichever mode is currently armed, or null when none is. The modes - * are mutually exclusive in practice (toggling one clears the others), so "the active mode" - * is well defined; if that ever stopped holding, the first match is still the one whose - * clicks the user is about to make. - */ - getActiveMeasurement(): MeasurementSettingsForType | null; - getSettingsByType(measurementType: MEASUREMENT_MODES_ENUM): void; -} -export declare const defaultMeasurementsSettings: { - isActive: boolean; - measurementType: string; - values: never[]; -}[]; diff --git a/dist/mixins/measurements/MeasurementSettingsHandler.js b/dist/mixins/measurements/MeasurementSettingsHandler.js deleted file mode 100644 index 1b076526..00000000 --- a/dist/mixins/measurements/MeasurementSettingsHandler.js +++ /dev/null @@ -1,49 +0,0 @@ -import { MEASUREMENT_MODES } from "../../enums"; -export class MeasurementSettingsHandler { - constructor(measurementsSettings) { - this.measurementsSettings = measurementsSettings; - this.isMeasurementActiveByType = this.isMeasurementActiveByType.bind(this); - } - isMeasurementActiveByType(measurementType) { - const settingsForType = this.measurementsSettings.find((setting) => setting.measurementType === measurementType); - return Boolean(settingsForType === null || settingsForType === void 0 ? void 0 : settingsForType.isActive); - } - updateMeasurementSettingsByType(newSettings) { - const settingsForType = this.measurementsSettings.find((setting) => setting.measurementType === newSettings.measurementType); - if (settingsForType) { - Object.assign(settingsForType, newSettings); - } - } - /** - * The settings entry for whichever mode is currently armed, or null when none is. The modes - * are mutually exclusive in practice (toggling one clears the others), so "the active mode" - * is well defined; if that ever stopped holding, the first match is still the one whose - * clicks the user is about to make. - */ - getActiveMeasurement() { - return this.measurementsSettings.find((setting) => setting.isActive) || null; - } - getSettingsByType(measurementType) { - const settingsForType = this.measurementsSettings.find((setting) => setting.measurementType === measurementType); - if (!settingsForType) { - throw new Error(`No settings found for measurement type ${measurementType}`); - } - } -} -export const defaultMeasurementsSettings = [ - { - isActive: false, - measurementType: MEASUREMENT_MODES.DISTANCE, - values: [], - }, - { - isActive: false, - measurementType: MEASUREMENT_MODES.ANGLE, - values: [], - }, - { - isActive: false, - measurementType: MEASUREMENT_MODES.COORDINATE, - values: [], - }, -]; diff --git a/dist/mixins/measurements/all.d.ts b/dist/mixins/measurements/all.d.ts deleted file mode 100644 index 34fbe32b..00000000 --- a/dist/mixins/measurements/all.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { MEASUREMENT_MODES_ENUM } from "../../enums"; -import { AnglesMeasurementManager } from "./angle"; -import { CoordinatesMeasurementManager } from "./coordinate"; -import { DistancesMeasurementManager } from "./distance"; -import { MeasurementSettingsHandler } from "./MeasurementSettingsHandler"; -export declare const AllMeasurementsMixin: (superclass: any) => { - new (): { - [x: string]: any; - measurementManagers: (CoordinatesMeasurementManager | DistancesMeasurementManager | AnglesMeasurementManager)[]; - bypassReloadViewer: boolean; - initializeMeasurementManagers(updateState: any): void; - getMeasurementManagerByType(measurementType: MEASUREMENT_MODES_ENUM): AnglesMeasurementManager | CoordinatesMeasurementManager | DistancesMeasurementManager | undefined; - getActiveMeasurementManager(): AnglesMeasurementManager | CoordinatesMeasurementManager | DistancesMeasurementManager | undefined; - getMeasurementsSettings(): { - isActive: boolean; - measurementType: MEASUREMENT_MODES_ENUM; - values: any[]; - selectedAtomsCount: number; - atomsPerMeasurement: number; - }[]; - getMeasurementsSettingsHandler(): MeasurementSettingsHandler; - toggleMeasurementByType(measurementType: MEASUREMENT_MODES_ENUM, updateState: any): void; - createAllMeasurements(): void; - resetAllMeasurements(): void; - deleteConnection(): void; - }; - [x: string]: any; -}; diff --git a/dist/mixins/measurements/all.js b/dist/mixins/measurements/all.js deleted file mode 100644 index 743b4bc5..00000000 --- a/dist/mixins/measurements/all.js +++ /dev/null @@ -1,58 +0,0 @@ -import { AnglesMeasurementManager } from "./angle"; -import { CoordinatesMeasurementManager } from "./coordinate"; -import { DistancesMeasurementManager } from "./distance"; -import { MeasurementSettingsHandler } from "./MeasurementSettingsHandler"; -export const AllMeasurementsMixin = (superclass) => class extends superclass { - constructor() { - super(...arguments); - this.measurementManagers = []; - this.bypassReloadViewer = false; - } - initializeMeasurementManagers(updateState) { - const distancesMeasurementManager = new DistancesMeasurementManager(this.structureGroup, this.camera, this, updateState); - const coordinatesMeasurementManager = new CoordinatesMeasurementManager(this.structureGroup, this.camera, this, updateState); - const anglesMeasurementManager = new AnglesMeasurementManager(this.structureGroup, this.camera, this, updateState); - this.measurementManagers.push(coordinatesMeasurementManager, distancesMeasurementManager, anglesMeasurementManager); - } - getMeasurementManagerByType(measurementType) { - return this.measurementManagers.find((m) => m.measurementType === measurementType); - } - getActiveMeasurementManager() { - return this.measurementManagers.find((m) => m.isActive); - } - getMeasurementsSettings() { - return this.measurementManagers.map((m) => m.getSettings()); - } - getMeasurementsSettingsHandler() { - return new MeasurementSettingsHandler(this.getMeasurementsSettings()); - } - toggleMeasurementByType(measurementType, updateState) { - if (!this.measurementManagers.length) { - this.initializeMeasurementManagers(updateState); - } - const measurementManager = this.getMeasurementManagerByType(measurementType); - const activeManager = this.getActiveMeasurementManager(); - if (activeManager && activeManager.measurementType !== measurementType) { - activeManager.toggleActive(); - } - measurementManager === null || measurementManager === void 0 ? void 0 : measurementManager.toggleActive(); - } - createAllMeasurements() { - const activeMeasurementManager = this.getActiveMeasurementManager(); - activeMeasurementManager === null || activeMeasurementManager === void 0 ? void 0 : activeMeasurementManager.createMeasurements(); - } - resetAllMeasurements() { - this.measurementManagers.forEach((manager) => manager.resetMeasurements()); - } - deleteConnection() { - const activeManager = this.getActiveMeasurementManager(); - if (activeManager && activeManager.currentSelectedLine) { - activeManager.deleteSelectedLine(); - this.render(); - // Update state if needed - if (activeManager.updateState) { - activeManager.updateState(activeManager.getSettings()); - } - } - } -}; diff --git a/dist/mixins/measurements/angle.d.ts b/dist/mixins/measurements/angle.d.ts deleted file mode 100644 index 026a37fb..00000000 --- a/dist/mixins/measurements/angle.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as THREE from "three"; -import { MEASUREMENT_MODES_ENUM } from "../../enums"; -import { AngleLabelsManager } from "../labels/angle"; -import { LabelsManagerConstructor } from "../labels/base"; -import { BaseMeasurementManager } from "./base"; -export declare class AnglesMeasurementManager extends BaseMeasurementManager { - measurementType: MEASUREMENT_MODES_ENUM; - atomsPerMeasurement: number; - LabelsManagerCls: LabelsManagerConstructor; - constructor(waveStructureGroup: THREE.Group, waveCamera: THREE.Camera, wave: any, updateState: (arg: object) => void); - onClick(updateState: (arg: object) => void, event: MouseEvent): void; - toggleAtomSelection(atom: THREE.Object3D): void; - setAtomAsSelected(atom: THREE.Object3D): void; - extractMeasurementValues(): number[]; - getLabelObjectsFromSelectedObjects(): THREE.Object3D[]; - getAdditionalObjectsFromSelectedObjects(): THREE.Line[]; - getTripletsOfSelectedAtoms(): THREE.Object3D[][]; - getLinesFromSelectedAtoms(): THREE.Line[]; - getAnglesFromSelectedAtoms(): number[]; -} diff --git a/dist/mixins/measurements/angle.js b/dist/mixins/measurements/angle.js deleted file mode 100644 index 484eb566..00000000 --- a/dist/mixins/measurements/angle.js +++ /dev/null @@ -1,63 +0,0 @@ -import * as THREE from "three"; -import { MEASUREMENT_MODES_ENUM } from "../../enums"; -import { AngleLabelsManager } from "../labels/angle"; -import { calculateAngleBetweenAtoms, calculateAngleLabelPosition } from "../utils_three"; -import { BaseMeasurementManager } from "./base"; -export class AnglesMeasurementManager extends BaseMeasurementManager { - constructor(waveStructureGroup, waveCamera, wave, updateState) { - const groupName = MEASUREMENT_MODES_ENUM.ANGLE; - super(waveStructureGroup, waveCamera, wave, groupName, updateState); - this.measurementType = MEASUREMENT_MODES_ENUM.ANGLE; - this.atomsPerMeasurement = 3; - this.LabelsManagerCls = AngleLabelsManager; - this.labelsManager = this.getLabelsManagerInstance(); - } - // @ts-ignore - onClick(updateState, event) { - super.onClick(event); - updateState(this.getSettings()); - this.createMeasurements(); - } - toggleAtomSelection(atom) { - this.setAtomAsSelected(atom); - } - setAtomAsSelected(atom) { - atom.userData.selected = true; - this.selectedAtoms.push(atom); - } - extractMeasurementValues() { - return this.getAnglesFromSelectedAtoms().map((angle) => angle); - } - getLabelObjectsFromSelectedObjects() { - const triplets = this.getTripletsOfSelectedAtoms(); - const angles = this.getAnglesFromSelectedAtoms(); - return triplets.map((triplet, index) => { - const object = new THREE.Object3D(); - const middleCoordinate = calculateAngleLabelPosition(triplet); - object.position.copy(middleCoordinate); - object.userData.angle = angles[index]; - return object; - }); - } - getAdditionalObjectsFromSelectedObjects() { - return this.getLinesFromSelectedAtoms(); - } - getTripletsOfSelectedAtoms() { - const arr = this.selectedAtoms; - return Array.from({ length: Math.floor(arr.length / 3) }, (_, i) => arr.slice(i * 3, i * 3 + 3)); - } - getLinesFromSelectedAtoms() { - const triplets = this.getTripletsOfSelectedAtoms(); - const lines = []; - triplets.forEach((triplet) => { - if (triplet.length === 3) { - const angleLine = this.linesManager.createAngleBetweenAtoms(triplet[0], triplet[1], triplet[2]); - lines.push(angleLine); - } - }); - return lines; - } - getAnglesFromSelectedAtoms() { - return this.getTripletsOfSelectedAtoms().map((triplet) => calculateAngleBetweenAtoms(triplet)); - } -} diff --git a/dist/mixins/measurements/base.d.ts b/dist/mixins/measurements/base.d.ts deleted file mode 100644 index 02069dcc..00000000 --- a/dist/mixins/measurements/base.d.ts +++ /dev/null @@ -1,77 +0,0 @@ -import * as THREE from "three"; -import { MEASUREMENT_MODES_ENUM } from "../../enums"; -import { BaseTHREEGroupManager } from "../base"; -import { BaseLabelsManager, LabelsManagerConstructor } from "../labels/base"; -import { LinesManager } from "../lines/LinesManager"; -declare const BaseManager: { - new (...args: any[]): { - raycaster: THREE.Raycaster; - pointer: THREE.Vector2; - intersectedObject: THREE.Object3D | null; - initRaycaster(): void; - checkMouseCoordinates(event: MouseEvent, camera: THREE.Camera): void; - canvas: HTMLCanvasElement; - onClick(event: MouseEvent): void; - onPointerMove(event: MouseEvent): void; - destroyListeners(): void; - initListeners(updateState: (arg: object) => void): void; - }; -} & typeof BaseTHREEGroupManager; -/** - * Base class for managing measurements. - * Contains generic logic for handling measurements: toggling measurement, selecting atoms, creating labels. - */ -export declare class BaseMeasurementManager extends BaseManager { - measurementType: MEASUREMENT_MODES_ENUM; - selectedAtoms: THREE.Object3D[]; - isActive: boolean; - /** - * How many atom picks one measurement of this type consumes: a distance needs a pair, an - * angle a triplet, a coordinate copy just the one. Reported through getSettings() so the UI - * can say how many picks are still outstanding without hardcoding the arity a second time - - * the managers group their own selections by this number (getPairsOfSelectedAtoms, - * getTripletsOfSelectedAtoms), so this is the same fact, not a copy of it. - */ - atomsPerMeasurement: number; - values: any[]; - LabelsManagerCls: LabelsManagerConstructor; - labelsManager: any; - linesManager: LinesManager; - updateState: any; - currentSelectedLine: THREE.Line | null; - constructor(waveStructureGroup: THREE.Group, waveCamera: THREE.Camera, wave: any, groupName: string, updateState: any); - protected getLabelsManagerInstance(): T; - toggleActive: () => void; - getSelectedAtomIndices(): any[]; - getAtomObjectByAtomicIndex(atomicIndex: number): any; - setAtomAsSelected(atomObject: THREE.Object3D): void; - unsetAtomAsSelected(atomObject: THREE.Object3D): void; - setIntersectedAtom(intersectItem: THREE.Object3D | null): void; - isIntersectedAtomSelected(): boolean; - getIntersections(): THREE.Intersection>[]; - toggleAtomSelection(atomObject: THREE.Object3D): void; - toggleLineSelection(line: THREE.Line): void; - refillSelectedAtoms(): void; - getSettings(): { - isActive: boolean; - measurementType: MEASUREMENT_MODES_ENUM; - values: any[]; - selectedAtomsCount: number; - atomsPerMeasurement: number; - }; - onClick(event: MouseEvent): void; - onPointerMove: (event: MouseEvent) => void; - copyValuesToClipboard(): void; - extractMeasurementValues(): number[] | number[][]; - createMeasurementLabel(text: string, name: string, position: THREE.Vector3, threeGroup?: THREE.Group): void; - getLabelObjectsFromSelectedObjects(): THREE.Object3D[]; - getAdditionalObjectsFromSelectedObjects(): THREE.Object3D[]; - createMeasurements(): void; - highlightSelectedAtoms(): void; - resetMeasurements(): void; - handleLineSelection(line: THREE.Line): void; - handleLineDeselection(line: THREE.Line): void; - removeAtomsFromSelectionByIndices(atomicIndices: number[]): void; - deleteSelectedLine(): void; -} -export {}; diff --git a/dist/mixins/measurements/base.js b/dist/mixins/measurements/base.js deleted file mode 100644 index 7914e9e3..00000000 --- a/dist/mixins/measurements/base.js +++ /dev/null @@ -1,242 +0,0 @@ -import { MEASUREMENT_MODES_ENUM } from "../../enums"; -import { BaseTHREEGroupManager } from "../base"; -import { LinesManager } from "../lines/LinesManager"; -import { RaycasterMixinWithListeners } from "../listeners/mixins"; -import { getObjectCoordinateAsArray, highlightAtom, isIntersectionObjectAnAtom, isObjectAnAtom, setAtomAsHovered, setColorForAtom, unsetAtomAsHovered, } from "../utils_three"; -const BaseManager = RaycasterMixinWithListeners(BaseTHREEGroupManager); -/** - * Base class for managing measurements. - * Contains generic logic for handling measurements: toggling measurement, selecting atoms, creating labels. - */ -export class BaseMeasurementManager extends BaseManager { - constructor(waveStructureGroup, waveCamera, wave, groupName, updateState) { - super(waveStructureGroup, waveCamera, wave, groupName + "-measurement-group"); - this.measurementType = MEASUREMENT_MODES_ENUM.NONE; - this.selectedAtoms = []; - this.isActive = false; - /** - * How many atom picks one measurement of this type consumes: a distance needs a pair, an - * angle a triplet, a coordinate copy just the one. Reported through getSettings() so the UI - * can say how many picks are still outstanding without hardcoding the arity a second time - - * the managers group their own selections by this number (getPairsOfSelectedAtoms, - * getTripletsOfSelectedAtoms), so this is the same fact, not a copy of it. - */ - this.atomsPerMeasurement = 1; - this.currentSelectedLine = null; - this.toggleActive = () => { - this.isActive = !this.isActive; - if (this.isActive) { - this.wave.setCursorStyle("pointer"); - this.initListeners(this.updateState); - } - else { - this.destroyListeners(); - this.wave.setCursorStyle(); - } - this.toggleVisibility(); - }; - this.onPointerMove = (event) => { - if (!this.isActive) - return; - this.checkMouseCoordinates(event, this.waveCamera); - const intersects = this.getIntersections(); - intersects.forEach((object) => { - if (isIntersectionObjectAnAtom(object)) { - this.setIntersectedAtom(object.object); - setAtomAsHovered(object.object); - } - }); - if (!intersects.length && this.intersectedObject) { - const isSelected = this.isIntersectedAtomSelected(); - if (this.intersectedObject && !isSelected && isObjectAnAtom(this.intersectedObject)) { - unsetAtomAsHovered(this.intersectedObject); - } - this.setIntersectedAtom(null); - } - this.wave.render(); - }; - this.initRaycaster(); - this.selectedAtoms = []; - this.intersectedObject = null; - this.canvas = wave.renderer.domElement; - this.linesManager = new LinesManager(waveStructureGroup, waveCamera, wave, groupName); - this.updateState = updateState; - } - getLabelsManagerInstance() { - return new this.LabelsManagerCls(this.waveStructureGroup, this.waveCamera, this.wave); - } - getSelectedAtomIndices() { - // TODO: refactor to use atom names getter from createAtomGroups - return this.selectedAtoms.map((atom) => atom.userData.atomicIndex); - } - getAtomObjectByAtomicIndex(atomicIndex) { - return this.wave - .getAtomGroups() - .find((atom) => atom.userData.atomicIndex === atomicIndex); - } - setAtomAsSelected(atomObject) { - atomObject.userData.selected = true; - const selectedAtomIndices = this.getSelectedAtomIndices(); - if (!selectedAtomIndices.includes(atomObject.userData.atomicIndex)) { - this.selectedAtoms.push(atomObject); - } - highlightAtom(atomObject); - } - unsetAtomAsSelected(atomObject) { - atomObject.userData.selected = false; - setColorForAtom(atomObject); - this.selectedAtoms = this.selectedAtoms.filter((atom) => atom.userData.atomicIndex !== atomObject.userData.atomicIndex); - } - setIntersectedAtom(intersectItem) { - if (intersectItem === null) { - this.intersectedObject = null; - return; - } - if (this.intersectedObject !== intersectItem && isObjectAnAtom(intersectItem)) { - this.intersectedObject = intersectItem; - } - } - isIntersectedAtomSelected() { - if (!this.intersectedObject) - return false; - return this.selectedAtoms.some((atom) => { var _a; return atom.userData.atomicIndex === ((_a = this.intersectedObject) === null || _a === void 0 ? void 0 : _a.userData.atomicIndex); }); - } - getIntersections() { - return this.raycaster.intersectObjects([...this.wave.getAtomGroups(), ...this.linesManager.getLines()], true); - } - toggleAtomSelection(atomObject) { - if (this.getSelectedAtomIndices().includes(atomObject.userData.atomicIndex)) { - this.unsetAtomAsSelected(atomObject); - } - else { - this.setAtomAsSelected(atomObject); - } - } - toggleLineSelection(line) { - if (line.userData.selected) { - this.handleLineDeselection(line); - } - else { - this.handleLineSelection(line); - } - } - refillSelectedAtoms() { - const validAtoms = []; - this.selectedAtoms.forEach((atom) => { - const { atomicIndex } = atom.userData; - const validAtom = this.getAtomObjectByAtomicIndex(atomicIndex); - if (validAtom) { - validAtoms.push(validAtom); - } - }); - this.selectedAtoms = validAtoms; - } - getSettings() { - return { - isActive: this.isActive, - measurementType: this.measurementType, - values: this.values, - // Picks made so far, so a partially-specified measurement ("1 of 2 picked") is - // visible instead of leaving the user guessing why nothing has been measured yet. - selectedAtomsCount: this.selectedAtoms.length, - atomsPerMeasurement: this.atomsPerMeasurement, - }; - } - onClick(event) { - if (!this.isActive) - return; - this.checkMouseCoordinates(event, this.waveCamera); - const intersects = this.getIntersections(); - intersects.forEach((object) => { - if (isIntersectionObjectAnAtom(object)) { - const atom = object.object; - this.toggleAtomSelection(atom); - } - if (object.object.type === "Line") { - const line = object.object; - this.toggleLineSelection(line); - } - }); - this.copyValuesToClipboard(); - } - copyValuesToClipboard() { - const values = this.extractMeasurementValues(); - let valuesText = ""; - if (values.length > 1) { - valuesText = JSON.stringify(values); - } - else if (values.length === 1) { - valuesText = JSON.stringify(values[0]); - } - navigator.clipboard.writeText(valuesText).catch(console.error); - } - extractMeasurementValues() { - const values = this.selectedAtoms.map((atom) => getObjectCoordinateAsArray(atom)); - this.values = values; - return values; - } - createMeasurementLabel(text, name, position, threeGroup = this.THREEGroup) { - const managerInstance = new this.LabelsManagerCls(this.waveStructureGroup, this.waveCamera, this.wave); - const label = managerInstance.createLabelSprite(text, name); - label.position.copy(position); - threeGroup.add(label); - this.waveStructureGroup.add(threeGroup); - } - getLabelObjectsFromSelectedObjects() { - return this.selectedAtoms; - } - getAdditionalObjectsFromSelectedObjects() { - return []; - } - createMeasurements() { - if (!this.selectedAtoms.length || !this.isActive) - return; - this.refillSelectedAtoms(); - this.labelsManager.createLabels(this.getLabelObjectsFromSelectedObjects(), this.THREEGroup); - this.getAdditionalObjectsFromSelectedObjects().forEach((object) => { - this.THREEGroup.add(object); - }); - this.highlightSelectedAtoms(); - } - highlightSelectedAtoms() { - this.selectedAtoms.forEach((atom) => { - highlightAtom(atom); - }); - } - resetMeasurements() { - this.selectedAtoms.forEach((atom) => { - this.unsetAtomAsSelected(atom); - }); - this.selectedAtoms = []; - this.values = []; - this.THREEGroup.clear(); - this.labelsManager.THREEGroup.clear(); - } - handleLineSelection(line) { - this.linesManager.setLineAsSelected(line); - this.currentSelectedLine = line; - } - handleLineDeselection(line) { - this.linesManager.unsetLineAsSelected(line); - this.currentSelectedLine = null; - } - removeAtomsFromSelectionByIndices(atomicIndices) { - this.selectedAtoms = this.selectedAtoms.filter((atom) => !atomicIndices.includes(atom.userData.atomicIndex)); - atomicIndices.forEach((index) => { - const atom = this.getAtomObjectByAtomicIndex(index); - if (atom) { - atom.userData.selected = false; - setColorForAtom(atom); - } - }); - } - deleteSelectedLine() { - if (this.currentSelectedLine) { - const atomicIndices = this.currentSelectedLine.userData.atomicIndices || []; - this.linesManager.removeLine(this.currentSelectedLine); - this.removeAtomsFromSelectionByIndices(atomicIndices); - this.currentSelectedLine = null; - this.createMeasurements(); - } - } -} diff --git a/dist/mixins/measurements/coordinate.d.ts b/dist/mixins/measurements/coordinate.d.ts deleted file mode 100644 index f34d29b0..00000000 --- a/dist/mixins/measurements/coordinate.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as THREE from "three"; -import { MEASUREMENT_MODES_ENUM } from "../../enums"; -import { LabelsManagerConstructor } from "../labels/base"; -import { CoordinateLabelsManager } from "../labels/coordinate"; -import { BaseMeasurementManager } from "./base"; -export declare class CoordinatesMeasurementManager extends BaseMeasurementManager { - measurementType: MEASUREMENT_MODES_ENUM; - LabelsManagerCls: LabelsManagerConstructor; - config: { - areSpritesUsed: boolean; - fontFace: string; - fontSize: number; - fontWeight: string; - scale: number; - scaleWidth: number; - scaleHeight: number; - offsetVector: number[]; - textParameters: { - fillStyle: string; - strokeStyle: string; - lineWidth: number; - textAlign: string; - textBaseline: string; - }; - }; - constructor(waveStructureGroup: THREE.Group, waveCamera: THREE.Camera, wave: any, updateState: any); - onClick: (updateState: (arg: object) => void, event: MouseEvent) => void; -} diff --git a/dist/mixins/measurements/coordinate.js b/dist/mixins/measurements/coordinate.js deleted file mode 100644 index 460d78c8..00000000 --- a/dist/mixins/measurements/coordinate.js +++ /dev/null @@ -1,18 +0,0 @@ -import { MEASUREMENT_MODES_ENUM } from "../../enums"; -import settings from "../../settings"; -import { CoordinateLabelsManager } from "../labels/coordinate"; -import { BaseMeasurementManager } from "./base"; -export class CoordinatesMeasurementManager extends BaseMeasurementManager { - constructor(waveStructureGroup, waveCamera, wave, updateState) { - super(waveStructureGroup, waveCamera, wave, MEASUREMENT_MODES_ENUM.COORDINATE, updateState); - this.measurementType = MEASUREMENT_MODES_ENUM.COORDINATE; - this.LabelsManagerCls = CoordinateLabelsManager; - this.config = settings.coordinateLabelsConfig; - // @ts-ignore - this.onClick = (updateState, event) => { - super.onClick(event); - updateState(this.getSettings()); - }; - this.labelsManager = this.getLabelsManagerInstance(); - } -} diff --git a/dist/mixins/measurements/distance.d.ts b/dist/mixins/measurements/distance.d.ts deleted file mode 100644 index d0145e91..00000000 --- a/dist/mixins/measurements/distance.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as THREE from "three"; -import { MEASUREMENT_MODES_ENUM } from "../../enums"; -import { LabelsManagerConstructor } from "../labels/base"; -import { DistanceLabelsManager } from "../labels/distance"; -import { BaseMeasurementManager } from "./base"; -export declare class DistancesMeasurementManager extends BaseMeasurementManager { - measurementType: MEASUREMENT_MODES_ENUM; - atomsPerMeasurement: number; - LabelsManagerCls: LabelsManagerConstructor; - constructor(waveStructureGroup: THREE.Group, waveCamera: THREE.Camera, wave: any, updateState: (arg: object) => void); - onClick(updateState: (arg: object) => void, event: MouseEvent): void; - toggleAtomSelection(atom: THREE.Object3D): void; - setAtomAsSelected(atom: THREE.Object3D): void; - extractMeasurementValues(): number[]; - getLabelObjectsFromSelectedObjects(): THREE.Object3D[]; - getAdditionalObjectsFromSelectedObjects(): THREE.Line[]; - getPairsOfSelectedAtoms(): THREE.Object3D[][]; - getLinesFromSelectedAtoms(): THREE.Line[]; - getLineLengthsFromSelectedAtoms(): number[]; - getLineCentersFromSelectedAtoms(): THREE.Vector3[]; -} diff --git a/dist/mixins/measurements/distance.js b/dist/mixins/measurements/distance.js deleted file mode 100644 index caa6d83b..00000000 --- a/dist/mixins/measurements/distance.js +++ /dev/null @@ -1,61 +0,0 @@ -import * as THREE from "three"; -import { MEASUREMENT_MODES_ENUM } from "../../enums"; -import { DistanceLabelsManager } from "../labels/distance"; -import { calculateDistanceBetweenAtoms } from "../utils_three"; -import { BaseMeasurementManager } from "./base"; -export class DistancesMeasurementManager extends BaseMeasurementManager { - constructor(waveStructureGroup, waveCamera, wave, updateState) { - const groupName = MEASUREMENT_MODES_ENUM.DISTANCE; - super(waveStructureGroup, waveCamera, wave, groupName, updateState); - this.measurementType = MEASUREMENT_MODES_ENUM.DISTANCE; - this.atomsPerMeasurement = 2; - this.LabelsManagerCls = DistanceLabelsManager; - this.labelsManager = new DistanceLabelsManager(waveStructureGroup, waveCamera, wave, groupName); - this.currentSelectedLine = null; - } - // @ts-ignore - onClick(updateState, event) { - super.onClick(event); - updateState(this.getSettings()); - this.createMeasurements(); - } - toggleAtomSelection(atom) { - // The same atom can be selected multiple times for different pairs - this.setAtomAsSelected(atom); - } - setAtomAsSelected(atom) { - atom.userData.selected = true; - this.selectedAtoms.push(atom); - } - extractMeasurementValues() { - return this.getLineLengthsFromSelectedAtoms(); - } - getLabelObjectsFromSelectedObjects() { - const lineCenters = this.getLineCentersFromSelectedAtoms(); - const distances = this.getLineLengthsFromSelectedAtoms(); - return lineCenters.map((position, index) => { - const object = new THREE.Object3D(); - object.position.copy(position); - object.userData.distance = distances[index]; - return object; - }); - } - getAdditionalObjectsFromSelectedObjects() { - return this.getLinesFromSelectedAtoms(); - } - getPairsOfSelectedAtoms() { - const arr = this.selectedAtoms; - return Array.from({ length: Math.floor(arr.length / 2) }, (_, i) => arr.slice(i * 2, i * 2 + 2)); - } - getLinesFromSelectedAtoms() { - const pairs = this.getPairsOfSelectedAtoms(); - return this.linesManager.createLinesFromAtomPairs(pairs); - } - getLineLengthsFromSelectedAtoms() { - return this.getPairsOfSelectedAtoms().map((atoms) => calculateDistanceBetweenAtoms(atoms[0], atoms[1])); - } - getLineCentersFromSelectedAtoms() { - const lines = this.getLinesFromSelectedAtoms(); - return lines.map((line) => this.linesManager.getLineCenterPosition(line)); - } -} diff --git a/dist/mixins/repetition.d.ts b/dist/mixins/repetition.d.ts deleted file mode 100644 index c19feee8..00000000 --- a/dist/mixins/repetition.d.ts +++ /dev/null @@ -1,47 +0,0 @@ -export function RepetitionMixin(superclass: any): { - new (): { - [x: string]: any; - /** - * Returns an array of coordinates (lattice points) to repeat the 3D objects bases on the number of repetitions. - * The method should get the maximum number of repetitions in one of the vectors (numberOfRepetitions) - */ - repetitionCoordinates(numberOfRepetitions: any): [number, number, number][]; - /** - * The method receives coordinates in the form of a cube (NxNxN) and repetitions we want to display - * Returns a new array based on the received data - */ - coordinatesByAxes(coordinates: any, repetitions: any): any; - /** - * Gets repetition information including coordinates and dimensions. - * Used by both object and atom repetition functions. - */ - getRepetitionInfo(): { - coordinates: any; - originalRepetitions: { - repetitionsAlongLatticeVectorA: any; - repetitionsAlongLatticeVectorB: any; - repetitionsAlongLatticeVectorC: any; - }; - dimensions: { - dimA: any; - dimB: any; - dimC: any; - }; - }; - /** - * Repeats a given 3D object at the lattice points given by repetitionCoordinates function. - */ - repeatObject3DAtRepetitionCoordinates(object3D: any): void; - /** - * Repeats a given 3D atom at the lattice points given by repetitionCoordinates function. - * This function was added because previous one function for repeating atoms is not correct for the atoms - * with measurement functionality. - */ - repeatAtomsAtRepetitionCoordinates(object3D: any): void; - /** - * Creates a cloned atom with a unique atomic index based on its position in the repetition grid - */ - createClonedAtomWithUniqueIndex(originalAtom: any, gridPosition: any, dimensions: any, originalAtomCount: any, point: any): any; - }; - [x: string]: any; -}; diff --git a/dist/mixins/repetition.js b/dist/mixins/repetition.js deleted file mode 100644 index b5876fba..00000000 --- a/dist/mixins/repetition.js +++ /dev/null @@ -1,131 +0,0 @@ -import { Made } from "@mat3ra/made"; -import * as THREE from "three"; -import { ATOM_GROUP_NAME } from "../enums"; -export const RepetitionMixin = (superclass) => class extends superclass { - /** - * Returns an array of coordinates (lattice points) to repeat the 3D objects bases on the number of repetitions. - * The method should get the maximum number of repetitions in one of the vectors (numberOfRepetitions) - */ - repetitionCoordinates(numberOfRepetitions) { - const basis = this.basis.clone(); - basis.removeAllAtoms(); - basis.addAtom({ element: "X", coordinate: [0, 0, 0] }); - // avoid repeating in z direction if boundaries are enabled. - const repetitions = [ - numberOfRepetitions, - numberOfRepetitions, - this.areNonPeriodicBoundariesPresent ? 1 : numberOfRepetitions, - ]; - return Made.tools.basis.repeat(basis, repetitions).coordinates.map((c) => c.value); - } - /** - * The method receives coordinates in the form of a cube (NxNxN) and repetitions we want to display - * Returns a new array based on the received data - */ - coordinatesByAxes(coordinates, repetitions) { - const { repetitionsAlongLatticeVectorA, repetitionsAlongLatticeVectorB, repetitionsAlongLatticeVectorC, } = repetitions; - const maxNumberOfRepetitions = Math.max(repetitionsAlongLatticeVectorA, repetitionsAlongLatticeVectorB, repetitionsAlongLatticeVectorC); - if (!repetitionsAlongLatticeVectorA && - !repetitionsAlongLatticeVectorB && - !repetitionsAlongLatticeVectorC) - return coordinates; - let columns = coordinates.reduce((res, item, index) => { - if (index % maxNumberOfRepetitions === 0) { - res[res.length] = [item]; - } - else { - res[res.length - 1].push(item); - } - return res; - }, []); - if (repetitionsAlongLatticeVectorA < maxNumberOfRepetitions) { - columns = columns.slice(0, maxNumberOfRepetitions * repetitionsAlongLatticeVectorA); - } - if (repetitionsAlongLatticeVectorB < maxNumberOfRepetitions) { - columns = columns.filter((item, index) => index % maxNumberOfRepetitions < repetitionsAlongLatticeVectorB); - } - if (repetitionsAlongLatticeVectorC < maxNumberOfRepetitions) { - columns = columns.map((arr) => arr.filter((item, index) => index < repetitionsAlongLatticeVectorC)); - } - return columns.reduce((res, item) => { - res.push(...item); - return res; - }, []); - } - /** - * Gets repetition information including coordinates and dimensions. - * Used by both object and atom repetition functions. - */ - getRepetitionInfo() { - const { settings } = this; - const repetitions = { - repetitionsAlongLatticeVectorA: settings.repetitionsAlongLatticeVectorA, - repetitionsAlongLatticeVectorB: settings.repetitionsAlongLatticeVectorB, - repetitionsAlongLatticeVectorC: settings.repetitionsAlongLatticeVectorC, - }; - const maxRepetitions = Math.max(...Object.values(repetitions)); - const allCoordinates = this.repetitionCoordinates(maxRepetitions); - const addedObjectsCoordinates = this.coordinatesByAxes(allCoordinates, repetitions).slice(1); // Skip original position - return { - coordinates: addedObjectsCoordinates, - originalRepetitions: { ...repetitions }, - dimensions: { - dimA: repetitions.repetitionsAlongLatticeVectorA || 1, - dimB: repetitions.repetitionsAlongLatticeVectorB || 1, - dimC: repetitions.repetitionsAlongLatticeVectorC || 1, - }, - }; - } - /** - * Repeats a given 3D object at the lattice points given by repetitionCoordinates function. - */ - repeatObject3DAtRepetitionCoordinates(object3D) { - this.structureGroup.add(object3D); - const { coordinates } = this.getRepetitionInfo(); - coordinates.forEach((point) => { - const object3DClone = object3D.clone(); - object3DClone.position.add(new THREE.Vector3(...point)); - this.structureGroup.add(object3DClone); - }); - } - /** - * Repeats a given 3D atom at the lattice points given by repetitionCoordinates function. - * This function was added because previous one function for repeating atoms is not correct for the atoms - * with measurement functionality. - */ - repeatAtomsAtRepetitionCoordinates(object3D) { - this.structureGroup.add(object3D); - const { coordinates, dimensions } = this.getRepetitionInfo(); - const { dimB, dimC } = dimensions; - const originalAtomCount = object3D.children.length; - coordinates.forEach((point, pointIndex) => { - const object3DClone = new THREE.Group(); - object3DClone.name = ATOM_GROUP_NAME; - const indexC = pointIndex % dimC; - const indexB = Math.floor(pointIndex / dimC) % dimB; - const indexA = Math.floor(pointIndex / (dimC * dimB)); - object3D.children.forEach((child) => { - const newChild = this.createClonedAtomWithUniqueIndex(child, { indexA, indexB, indexC }, dimensions, originalAtomCount, point); - object3DClone.add(newChild); - }); - object3DClone.position.add(new THREE.Vector3(...point)); - this.structureGroup.add(object3DClone); - }); - } - /** - * Creates a cloned atom with a unique atomic index based on its position in the repetition grid - */ - createClonedAtomWithUniqueIndex(originalAtom, gridPosition, dimensions, originalAtomCount, point) { - const { indexA, indexB, indexC } = gridPosition; - const { dimB, dimC } = dimensions; - const clonedAtom = originalAtom.clone(true); - clonedAtom.material = originalAtom.material.clone(true); - if (clonedAtom.userData && clonedAtom.userData.atomicIndex !== undefined) { - // clonedAtom.userData.originalAtomicIndex = clonedAtom.userData.atomicIndex; - const uniqueIndexOffset = (indexA * dimB * dimC + indexB * dimC + indexC + 1) * originalAtomCount; - clonedAtom.userData.atomicIndex += uniqueIndexOffset; - clonedAtom.userData.worldPosition = new THREE.Vector3(...point).add(clonedAtom.position); - } - return clonedAtom; - } -}; diff --git a/dist/mixins/types/atoms.d.ts b/dist/mixins/types/atoms.d.ts deleted file mode 100644 index 8c68dd7a..00000000 --- a/dist/mixins/types/atoms.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as THREE from "three"; -export interface AtomUserData { - symbolWithLabel: string; - atomicIndex: number; - selected?: boolean; - hovered?: boolean; - connections?: string[]; - [key: string]: any; -} -export interface AtomMesh extends THREE.Mesh { - previousColor?: THREE.Color; - userData: AtomUserData; -} -export interface AtomObject extends THREE.Object3D { - userData: AtomUserData; -} diff --git a/dist/mixins/types/atoms.js b/dist/mixins/types/atoms.js deleted file mode 100644 index cb0ff5c3..00000000 --- a/dist/mixins/types/atoms.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/mixins/utils.d.ts b/dist/mixins/utils.d.ts deleted file mode 100644 index 1ee036bc..00000000 --- a/dist/mixins/utils.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -export function createGIFAsync({ images, gifWidth, gifHeight, numFrames, frameDuration, sampleInterval, }: { - images: any; - gifWidth: any; - gifHeight: any; - numFrames: any; - frameDuration: any; - sampleInterval: any; -}): Promise; -export function UtilsMixin(superclass: any): { - new (): { - [x: string]: any; - toggleBoolean(name: any, antagonistNames?: any[]): void; - areTwoObjectsShallowEqual(o1: any, o2: any): boolean; - getTwoObjectsShallowDifferentKeys(o1: any, o2: any): {}; - }; - [x: string]: any; -}; -export function ApplyGlow(meshObjet: Object, baseColor: string, offset?: number): void; diff --git a/dist/mixins/utils.js b/dist/mixins/utils.js deleted file mode 100644 index 996f87c9..00000000 --- a/dist/mixins/utils.js +++ /dev/null @@ -1,71 +0,0 @@ -import gifshot from "gifshot"; -import * as THREE from "three"; -export const UtilsMixin = (superclass) => class extends superclass { - // toggles a boolean variable and optionally sets all variables in the antagonists array to the opposite value - toggleBoolean(name, antagonistNames = []) { - this[name] = !this[name]; - // disable all antagonists when `name` variable is set to true - const currentValue = this[name]; - if (currentValue && antagonistNames.length) { - antagonistNames.forEach((antagonistName) => { - this[antagonistName] = !currentValue; - }); - } - } - areTwoObjectsShallowEqual(o1, o2) { - return Object.keys(o1) - .map((key) => o1[key] === o2[key]) - .reduce((a, b) => a && b); - } - getTwoObjectsShallowDifferentKeys(o1, o2) { - const resultingObject = {}; - const differentKeysArray = Object.keys(o1).filter((key) => o1[key] !== o2[key]); - differentKeysArray.forEach((key) => (resultingObject[key] = true)); - return resultingObject; - } -}; -/** - * Applies glow to a THREE object. - * @param meshObjet {Object}: THREE mesh object. - * @param baseColor {String}: hex color string of the glow - * @param offset {Number}: can be a single digit number, 0 means no offset - */ -export const ApplyGlow = (meshObjet, baseColor, offset = 0) => { - const atomHSL = {}; - new THREE.Color(baseColor).getHSL(atomHSL); - let hue, saturation; - if (offset !== 0) { - if (offset % 2 === 0) { - // even labels - hue = atomHSL.h + (offset * 0.1) / 2; - saturation = atomHSL.s + (offset * 0.1) / 2; - } - else { - // odd labels - hue = atomHSL.h - ((offset + 1) * 0.1) / 2; - saturation = atomHSL.s + ((offset + 1) * 0.1) / 2; - } - // hue is cyclic - while (hue > 1) { - hue -= 1; - } - while (hue < 0) { - hue += 1; - } - saturation = Math.max(0, Math.min(1, saturation)); - meshObjet.material.emissiveIntensity = 0.25; - meshObjet.material.emissive.setHSL(hue, saturation, atomHSL.l); - } -}; -export function createGIFAsync({ images, gifWidth, gifHeight, numFrames, frameDuration, sampleInterval, }) { - return new Promise((resolve, reject) => { - gifshot.createGIF({ images, gifWidth, gifHeight, numFrames, frameDuration, sampleInterval }, (obj) => { - if (!obj.error) { - resolve(obj.image); // Resolve with the GIF data URL - } - else { - reject(obj.error); // Reject with the error - } - }); - }); -} diff --git a/dist/mixins/utils_three.d.ts b/dist/mixins/utils_three.d.ts deleted file mode 100644 index 9d90835c..00000000 --- a/dist/mixins/utils_three.d.ts +++ /dev/null @@ -1,57 +0,0 @@ -import * as THREE from "three"; -import { AtomObject } from "./types/atoms"; -/** - * TODO: import from a shared utils file - * Converts radians to degrees - */ -export declare function radiansToDegrees(radians: number): number; -export declare function getArrayFromVector(vector: THREE.Vector3): number[]; -export declare function getObjectCoordinate(object: THREE.Object3D): THREE.Vector3; -export declare function getObjectCoordinateAsArray(object: THREE.Object3D): number[]; -/** - * Gets the world position of an atom, accounting for repetition - */ -export declare function getAtomWorldPosition(atom: THREE.Object3D): THREE.Vector3; -/** - * Calculates the angle between three points in 3D space - */ -export declare function calculateAngleBetweenPoints(pointA: THREE.Vector3, pointB: THREE.Vector3, pointC: THREE.Vector3): number; -/** - * Calculates angle between three atoms - */ -export declare function calculateAngleBetweenAtoms(atoms: THREE.Object3D[]): number; -/** - * Calculates the distance between two points - */ -export declare function calculateDistance(pointA: THREE.Vector3, pointB: THREE.Vector3): number; -/** - * Calculates distance between two atoms - */ -export declare function calculateDistanceBetweenAtoms(atomA: THREE.Object3D, atomB: THREE.Object3D): number; -/** - * Calculates the midpoint between two points - */ -export declare function calculateMidpoint(pointA: THREE.Vector3, pointB: THREE.Vector3): THREE.Vector3; -/** - * Creates a position for a label at an angle between three points - */ -export declare function calculateAngleLabelPosition([firstAtom, centerAtom, thirdAtom]: THREE.Object3D[], offsetDistance?: number): THREE.Vector3; -export declare function isIntersectionObjectAnAtom(intersection: THREE.Intersection): boolean; -/** - * Sets or resets the color for an atom by modifying its material properties. - * If no color is provided, it restores the previous color and removes emissive effects. - */ -export declare function setColorForAtom(atom: THREE.Object3D, color?: number): void; -/** - * Highlights an atom with the specified color. - */ -export declare function highlightAtom(atom: THREE.Object3D, color?: number): void; -/** - * Sets an atom as hovered with a color. - */ -export declare function setAtomAsHovered(atom: THREE.Object3D): void; -/** - * Unsets an atom as hovered, restoring its previous color and removing the emissive effect. - */ -export declare function unsetAtomAsHovered(atom: THREE.Object3D): void; -export declare function isObjectAnAtom(object: THREE.Object3D): object is AtomObject; diff --git a/dist/mixins/utils_three.js b/dist/mixins/utils_three.js deleted file mode 100644 index fefaa1dc..00000000 --- a/dist/mixins/utils_three.js +++ /dev/null @@ -1,134 +0,0 @@ -import * as THREE from "three"; -import { COLORS } from "../enums"; -/** - * TODO: import from a shared utils file - * Converts radians to degrees - */ -export function radiansToDegrees(radians) { - return radians * (180 / Math.PI); -} -export function getArrayFromVector(vector) { - return [vector.x, vector.y, vector.z]; -} -export function getObjectCoordinate(object) { - return new THREE.Vector3().setFromMatrixPosition(object.matrixWorld); -} -export function getObjectCoordinateAsArray(object) { - const position = getObjectCoordinate(object); - return getArrayFromVector(position); -} -/** - * Gets the world position of an atom, accounting for repetition - */ -export function getAtomWorldPosition(atom) { - const position = new THREE.Vector3(); - // If we have a cached world position (for repeated atoms), use it - if (atom.userData && atom.userData.worldPosition) { - position.copy(atom.userData.worldPosition); - } - else { - atom.getWorldPosition(position); - } - return position; -} -/** - * Calculates the angle between three points in 3D space - */ -export function calculateAngleBetweenPoints(pointA, pointB, pointC) { - const vecA = new THREE.Vector3().subVectors(pointA, pointB); - const vecC = new THREE.Vector3().subVectors(pointC, pointB); - const angleRadians = vecA.angleTo(vecC); - return radiansToDegrees(angleRadians); -} -/** - * Calculates angle between three atoms - */ -export function calculateAngleBetweenAtoms(atoms) { - const [firstAtom, centerAtom, lastAtom] = atoms; - const firstPos = getObjectCoordinate(firstAtom); - const centerPos = getObjectCoordinate(centerAtom); - const lastPos = getObjectCoordinate(lastAtom); - return parseFloat(calculateAngleBetweenPoints(firstPos, centerPos, lastPos).toFixed(2)); -} -/** - * Calculates the distance between two points - */ -export function calculateDistance(pointA, pointB) { - return pointA.distanceTo(pointB); -} -/** - * Calculates distance between two atoms - */ -export function calculateDistanceBetweenAtoms(atomA, atomB) { - const pointA = getObjectCoordinate(atomA); - const pointB = getObjectCoordinate(atomB); - return calculateDistance(pointA, pointB); -} -/** - * Calculates the midpoint between two points - */ -export function calculateMidpoint(pointA, pointB) { - return new THREE.Vector3().addVectors(pointA, pointB).multiplyScalar(0.5); -} -/** - * Creates a position for a label at an angle between three points - */ -export function calculateAngleLabelPosition([firstAtom, centerAtom, thirdAtom], offsetDistance = 0.75) { - const centerPos = getObjectCoordinate(centerAtom); - const firstPos = getObjectCoordinate(firstAtom); - const thirdPos = getObjectCoordinate(thirdAtom); - // Create vectors from center to first and third points - const vecFirst = new THREE.Vector3().subVectors(firstPos, centerPos).normalize(); - const vecThird = new THREE.Vector3().subVectors(thirdPos, centerPos).normalize(); - // Calculate the bisector - const bisector = new THREE.Vector3().addVectors(vecFirst, vecThird).normalize(); - // Position on the bisector at the given distance - return centerPos.clone().add(bisector.multiplyScalar(offsetDistance)); -} -export function isIntersectionObjectAnAtom(intersection) { - return intersection.object.type === "Mesh"; -} -/** - * Sets or resets the color for an atom by modifying its material properties. - * If no color is provided, it restores the previous color and removes emissive effects. - */ -export function setColorForAtom(atom, color) { - if (!(atom instanceof THREE.Mesh)) - return; - const atomMesh = atom; - const material = atomMesh.material; - if (!material) - return; - material.emissive.setHex(color !== null && color !== void 0 ? color : COLORS.BLACK); - if (!color && atomMesh.previousColor) { - material.color.copy(atomMesh.previousColor); - } - else if (color && !atomMesh.previousColor) { - atomMesh.previousColor = material.color.clone(); - } -} -/** - * Highlights an atom with the specified color. - */ -export function highlightAtom(atom, color = COLORS.RED) { - setColorForAtom(atom, color); -} -/** - * Sets an atom as hovered with a color. - */ -export function setAtomAsHovered(atom) { - const atomObject = atom; - atomObject.userData.hovered = true; - setColorForAtom(atom, COLORS.RED); -} -/** - * Unsets an atom as hovered, restoring its previous color and removing the emissive effect. - */ -export function unsetAtomAsHovered(atom) { - const atomObject = atom; - atomObject.userData.hovered = false; - setColorForAtom(atom); -} -export function isObjectAnAtom(object) { - return object instanceof THREE.Mesh; -} diff --git a/dist/settings.d.ts b/dist/settings.d.ts deleted file mode 100644 index af44e1cd..00000000 --- a/dist/settings.d.ts +++ /dev/null @@ -1,201 +0,0 @@ -import * as THREE from "three"; -declare module "@mat3ra/periodic-table" { - interface Element { - van_der_Waals_radius_pm: number; - } -} -declare const _default: { - atomRadiiScale: number; - repetitions: number; - chemicalConnectivityFactor: number; - defaultElement: string; - sphereRadius: number; - sphereQuality: number; - elementColors: any; - vdwRadii: Record; - lineWidth: number; - lineMaterial: { - dashSize: number; - gapSize: number; - scale: number; - linewidth: number; - }; - colors: { - amber: number; - gray: number; - }; - backgroundColor: string; - defaultColor: string; - initialCameraPosition: number[]; - roundPrecision: number; - isViewAdjustable: boolean; - labelsConfig: { - areSpritesUsed: boolean; - fontFace: string; - fontSize: number; - fontWeight: string; - scale: number; - scaleWidth: number; - scaleHeight: number; - textParameters: { - fillStyle: string; - strokeStyle: string; - lineWidth: number; - textAlign: string; - textBaseline: string; - }; - }; - elementLabelsConfig: { - areSpritesUsed: boolean; - fontFace: string; - fontSize: number; - fontWeight: string; - scale: number; - scaleWidth: number; - scaleHeight: number; - textParameters: { - fillStyle: string; - strokeStyle: string; - lineWidth: number; - textAlign: string; - textBaseline: string; - }; - }; - coordinateLabelsConfig: { - areSpritesUsed: boolean; - fontFace: string; - fontSize: number; - fontWeight: string; - scale: number; - scaleWidth: number; - scaleHeight: number; - offsetVector: number[]; - textParameters: { - fillStyle: string; - strokeStyle: string; - lineWidth: number; - textAlign: string; - textBaseline: string; - }; - }; - distanceLabelsConfig: { - areSpritesUsed: boolean; - fontFace: string; - fontSize: number; - fontWeight: string; - scale: number; - scaleWidth: number; - scaleHeight: number; - offsetVector: number[]; - textParameters: { - fillStyle: string; - strokeStyle: string; - lineWidth: number; - textAlign: string; - textBaseline: string; - }; - }; - angleLabelsConfig: { - areSpritesUsed: boolean; - fontFace: string; - fontSize: number; - fontWeight: string; - scale: number; - scaleWidth: number; - scaleHeight: number; - offsetVector: number[]; - textParameters: { - fillStyle: string; - strokeStyle: string; - lineWidth: number; - textAlign: string; - textBaseline: string; - }; - }; - labelPointsConfig: { - size: number; - depthTest: boolean; - depthFunc: THREE.DepthModes; - transparent: boolean; - }; - labelSpriteConfig: { - transparent: boolean; - depthFunc: THREE.DepthModes; - depthTest: boolean; - }; - boundaryConditionTypeColors: { - bc1: number[]; - bc2: number[]; - bc3: number[]; - }; - /** - * Single-character keys, dispatched from a `keypress` handler. Every entry here appears in - * the keyboard sheet automatically, so a rebind cannot leave a stale label behind - the - * drift that produced defect D2, where a tooltip promised a hotkey that did not exist. - */ - hotKeysConfig: { - toggleKeyboardSheet: string; - toggleOrbitControls: string; - toggleInteractive: string; - toggleBonds: string; - toggleElementLabels: string; - toggleCoordinateLabels: string; - resetViewer: string; - toggleDistanceShown: string; - toggleAnglesShown: string; - toggleCopyCoordinatesShown: string; - deleteConnection: string; - toggleEditMode: string; - focusCameraOnSelection: string; - }; - /** - * Editor keys that `keypress` never fires for - non-character keys and modifier combos - so - * they are handled on `keydown` instead. They used to be hardcoded inside - * ThreeDEditor.handleEditModeKeyDown, which meant the keyboard sheet had no way to know about - * them and they appeared in no tooltip or menu at all. Declaring them here makes the handler - * and the sheet read from one source. - * - * `usesModifier: true` means Ctrl on Windows/Linux and Cmd on macOS; `requiresShift` is - * additive on top of it. `keys` lists every key that triggers the action. - */ - editorKeysConfig: { - undo: { - keys: string[]; - usesModifier: boolean; - requiresShift: boolean; - label: string; - }; - redo: { - keys: string[]; - usesModifier: boolean; - requiresShift: boolean; - label: string; - }; - removeSelected: { - keys: string[]; - usesModifier: boolean; - requiresShift: boolean; - label: string; - }; - cancelOrDeselect: { - keys: string[]; - usesModifier: boolean; - requiresShift: boolean; - label: string; - }; - }; - measurementLabelsConfig: { - areSpritesUsed: boolean; - fontFace: string; - fontSize: number; - fontWeight: string; - fillStyle: string; - strokeStyle: string; - lineWidth: number; - textAlign: string; - textBaseline: string; - scaleWidth: number; - scaleHeight: number; - }; -}; -export default _default; diff --git a/dist/settings.js b/dist/settings.js deleted file mode 100644 index dd709939..00000000 --- a/dist/settings.js +++ /dev/null @@ -1,205 +0,0 @@ -// @ts-ignore // Types for this library are needed -import { ELEMENT_COLORS, PERIODIC_TABLE } from "@mat3ra/periodic-table"; -import * as THREE from "three"; -/** - * Van der Waals radii in Angstrom, keyed by element symbol. - * - * Must stay a symbol-keyed object: `getAtomRadiusByElement` (mixins/atoms.ts) looks radii up - * by element symbol, so building this with `Array.prototype.map` - which yields a positional - * array indexed 0..117 - made every lookup return undefined and silently fall back to - * `sphereRadius`, rendering every element at the same size. - */ -const vdwRadiiMapAngstrom = Object.fromEntries(Object.keys(PERIODIC_TABLE).map((elementSymbol) => [ - elementSymbol, - PERIODIC_TABLE[elementSymbol].van_der_Waals_radius_pm / 100, -])); -export default { - // atoms - // atoms.user-controllable - atomRadiiScale: 0.2, - repetitions: 1, - chemicalConnectivityFactor: 1.05, - // atoms.non-user-controllable - defaultElement: "Si", - sphereRadius: 1.5, - sphereQuality: 16, - elementColors: ELEMENT_COLORS, - vdwRadii: vdwRadiiMapAngstrom, - // line - lineWidth: 2, - lineMaterial: { - dashSize: 1, - gapSize: 2, - scale: 2, - linewidth: 2, - }, - colors: { - amber: 0xffc107, - gray: 0x808080, - }, - // general - backgroundColor: "#202020", - defaultColor: "#CCCCCC", - initialCameraPosition: [-50, 0, 10], - // labels - roundPrecision: 3, - isViewAdjustable: true, - labelsConfig: { - areSpritesUsed: true, - fontFace: "Arial", - fontSize: 96, - fontWeight: "Bold", - scale: 1, - scaleWidth: 0.5, - scaleHeight: 0.5, - textParameters: { - fillStyle: "#EEEEEE", - strokeStyle: "#454545", - lineWidth: 2, - textAlign: "center", - textBaseline: "middle", - }, - }, - elementLabelsConfig: { - areSpritesUsed: true, - fontFace: "Arial", - fontSize: 96, - fontWeight: "Bold", - scale: 1, - scaleWidth: 0.5, - scaleHeight: 0.5, - textParameters: { - fillStyle: "#EEEEEE", - strokeStyle: "#454545", - lineWidth: 2, - textAlign: "center", - textBaseline: "middle", - }, - }, - coordinateLabelsConfig: { - areSpritesUsed: true, - fontFace: "Arial", - fontSize: 72, - fontWeight: "Normal", - scale: 1.5, - scaleWidth: 2.5, - scaleHeight: 0.25, - offsetVector: [0, 0, 0.5], - textParameters: { - fillStyle: "#CCCCCC", - strokeStyle: "#454545", - lineWidth: 1, - textAlign: "center", - textBaseline: "middle", - }, - }, - distanceLabelsConfig: { - areSpritesUsed: true, - fontFace: "Arial", - fontSize: 72, - fontWeight: "Normal", - scale: 1.5, - scaleWidth: 2.5, - scaleHeight: 0.25, - offsetVector: [0, 0, 0], - textParameters: { - fillStyle: "#CCCCCC", - strokeStyle: "#454545", - lineWidth: 1, - textAlign: "center", - textBaseline: "middle", - }, - }, - angleLabelsConfig: { - areSpritesUsed: true, - fontFace: "Arial", - fontSize: 72, - fontWeight: "Normal", - scale: 1.5, - scaleWidth: 2.5, - scaleHeight: 0.25, - offsetVector: [0, 0, 0], - textParameters: { - fillStyle: "#CCCCCC", - strokeStyle: "#454545", - lineWidth: 1, - textAlign: "center", - textBaseline: "middle", - }, - }, - labelPointsConfig: { - size: 1.5, - depthTest: true, - depthFunc: THREE.NotEqualDepth, - transparent: true, - }, - labelSpriteConfig: { - transparent: true, - depthFunc: THREE.LessEqualDepth, - depthTest: true, - }, - boundaryConditionTypeColors: { - bc1: [0xffff00, 0xffff00], - bc2: [0x0000ff, 0x0000ff], - bc3: [0xffff00, 0x0000ff], - }, - /** - * Single-character keys, dispatched from a `keypress` handler. Every entry here appears in - * the keyboard sheet automatically, so a rebind cannot leave a stale label behind - the - * drift that produced defect D2, where a tooltip promised a hotkey that did not exist. - */ - hotKeysConfig: { - toggleKeyboardSheet: "?", - toggleOrbitControls: "o", - toggleInteractive: "i", - toggleBonds: "b", - toggleElementLabels: "e", - toggleCoordinateLabels: "k", - resetViewer: "r", - toggleDistanceShown: "d", - toggleAnglesShown: "a", - toggleCopyCoordinatesShown: "c", - deleteConnection: "x", - toggleEditMode: "t", - focusCameraOnSelection: "f", - }, - /** - * Editor keys that `keypress` never fires for - non-character keys and modifier combos - so - * they are handled on `keydown` instead. They used to be hardcoded inside - * ThreeDEditor.handleEditModeKeyDown, which meant the keyboard sheet had no way to know about - * them and they appeared in no tooltip or menu at all. Declaring them here makes the handler - * and the sheet read from one source. - * - * `usesModifier: true` means Ctrl on Windows/Linux and Cmd on macOS; `requiresShift` is - * additive on top of it. `keys` lists every key that triggers the action. - */ - editorKeysConfig: { - undo: { keys: ["z"], usesModifier: true, requiresShift: false, label: "Undo" }, - redo: { keys: ["z"], usesModifier: true, requiresShift: true, label: "Redo" }, - removeSelected: { - keys: ["Delete", "Backspace"], - usesModifier: false, - requiresShift: false, - label: "Remove selected", - }, - cancelOrDeselect: { - keys: ["Escape"], - usesModifier: false, - requiresShift: false, - label: "Cancel drag · deselect", - }, - }, - measurementLabelsConfig: { - areSpritesUsed: true, - fontFace: "Arial", - fontSize: 72, - fontWeight: "Normal", - fillStyle: "#CCCCCC", - strokeStyle: "#454545", - lineWidth: 1, - textAlign: "center", - textBaseline: "middle", - scaleWidth: 1.5, - scaleHeight: 0.4, - }, -}; diff --git a/dist/stylesheets/main.css b/dist/stylesheets/main.css deleted file mode 100644 index 9a56c0fc..00000000 --- a/dist/stylesheets/main.css +++ /dev/null @@ -1,26 +0,0 @@ -@charset "UTF-8"; -body { - font-family: Helvetica, Arial, sans-serif; - margin: 0; - overflow: hidden; -} - -.selectBox { - border: 1px solid #55aaff; - background-color: rgba(75, 160, 255, 0.3); - position: fixed; -} - -.three-renderer { - height: 100%; -} - -.three-renderer canvas:first-child { - position: absolute; - right: 10px; -} -.three-renderer .three-renderer-selection { - position: absolute; - background: rgba(255, 255, 255, 0.4); - visibility: hidden; -} diff --git a/dist/utils.d.ts b/dist/utils.d.ts deleted file mode 100644 index 13114d1d..00000000 --- a/dist/utils.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Converts a given scene data to a material. - * Lattice is constructed from the LineSegments object(s) representing the unit cell. - * Basis is constructed from the base structure's atom sphere meshes (see - * extractBasisFromScene above for what that excludes). - */ -export function ThreeDSceneDataToMaterial(scene: any): import("@mat3ra/made").Material; diff --git a/dist/utils.js b/dist/utils.js deleted file mode 100644 index 16c4689a..00000000 --- a/dist/utils.js +++ /dev/null @@ -1,144 +0,0 @@ -import { Made } from "@mat3ra/made"; -import * as THREE from "three"; -import { ATOM_GROUP_NAME } from "./enums"; -/** - * @summary Converts position array of Buffer Geometry to vertices vectors - * @param geometry {THREE.BufferGeometry} the buffer geometry - * @param position {Float32Array} the position array - */ -function convertPositionToVertices(geometry, position) { - const vertices = []; - for (let i = 0, l = position.count; i < l; i++) { - const vector = new THREE.Vector3(); - vector.fromBufferAttribute(position, i); - vector.applyMatrix4(geometry.matrixWorld); - vertices.push(vector); - } - return vertices; -} -/** - * Returns the THREE.Group Wave itself creates to hold the entire visible structure (atoms, unit - * cell, bonds, boundary planes, labels, measurements - see - * Wave.initStructureGroup()/createStructureGroup() in src/wave.js). It is the only object of - * type "Group" added directly to the scene, and it is added before any editor-only helper object - * (e.g. TransformControls, also a direct child of the scene but never of type "Group"), so this - * lookup - already relied on below to read the structure's name - unambiguously finds it. - */ -function getStructureGroup(scene) { - return scene.getObjectByProperty("type", "Group"); -} -/** - * Returns the direct-child LineSegments object(s) of the structure group that make up the - * unit-cell wireframe. CellMixin.drawUnitCell() (src/mixins/cell.ts) is the only place that adds - * LineSegments to the structure group, and it always adds them as direct children of it, so there - * is no need to search any deeper (and risk matching an unrelated LineSegments object elsewhere). - */ -function getUnitCellLineSegments(structureGroup) { - return structureGroup.children.filter((child) => child.isLineSegments); -} -/** - * Extracts the lattice from the LineSegments object(s) that draw the unit cell. - * - * CellMixin.getUnitCellObject() draws the full cell as a single LineSegments object named "Cell" - * with 24 vertices (12 edges x 2 endpoints) when boundary conditions are periodic. Under - * non-periodic boundary conditions, CellMixin.drawUnitCell() instead draws it as two unnamed, - * 16-vertex LineSegments objects split at z=0 (a "down" half added first with z-multiplier -0.5, - * then an "up" half added second with +0.5 - see getUnitCellObjectByEdges/getCellVertices) - so - * there is no single 24-vertex object to index into the way the periodic case allows, and picking - * "the first LineSegments found by scanning the scene" (the previous approach) is not reliable - * either, since it can return the wrong half or, given an unrelated future addition to the scene, - * an unrelated object entirely. Both cases are handled explicitly here by locating the actual - * cell object(s) via the structure group instead of guessing. - */ -function extractLatticeFromScene(scene) { - const structureGroup = getStructureGroup(scene); - const cellObjects = getUnitCellLineSegments(structureGroup); - if (cellObjects.length === 1) { - // Periodic case: getCellVertices(cell) with the default zMultiplier of 1 places corner 4 - // at the untouched tip of `c`, so vertex 17 - matching the `edges` array in - // getUnitCellObject - is the full c vector, exactly as vertex 1/3 are the full a/b. - const [cellObject] = cellObjects; - const vertices = convertPositionToVertices(cellObject, cellObject.geometry.attributes.position); - if (vertices.length !== 24) { - throw new Error(`extractLatticeFromScene: expected the periodic unit cell object to have 24 vertices, got ${vertices.length}`); - } - const a = vertices[1].sub(vertices[0]).toArray(); - const b = vertices[3].sub(vertices[0]).toArray(); - const c = vertices[17].sub(vertices[0]).toArray(); - return Made.Lattice.fromVectors({ a, b, c }); - } - if (cellObjects.length === 2) { - // Non-periodic case: both halves carry the full a/b vectors (getCellVertices only scales - // the z-multiplier into the z component of corner 4, never x/y), so a/b are read the same - // way as the periodic case above. Only c is split: the "up" half (added second) has - // vertex 9 - matching its own 16-entry `edges` array - at [cx, cy, cz * 0.5], so c is - // recovered by doubling just the z component of that delta rather than the whole vector. - const [, upCellObject] = cellObjects; - const vertices = convertPositionToVertices(upCellObject, upCellObject.geometry.attributes.position); - if (vertices.length !== 16) { - throw new Error(`extractLatticeFromScene: expected the non-periodic half unit cell object to have 16 vertices, got ${vertices.length}`); - } - const a = vertices[1].sub(vertices[0]).toArray(); - const b = vertices[3].sub(vertices[0]).toArray(); - const halfC = vertices[9].sub(vertices[0]); - const c = [halfC.x, halfC.y, halfC.z * 2]; - return Made.Lattice.fromVectors({ a, b, c }); - } - throw new Error(`extractLatticeFromScene: expected 1 (periodic) or 2 (non-periodic) unit cell LineSegments objects in the structure group, found ${cellObjects.length}`); -} -/** - * Extracts basis from the atom sphere meshes belonging to the base structure. - * The name of the element is extracted from the name of the corresponding 3D object. - * - * Traversal is scoped to the first child of the structure group named ATOM_GROUP_NAME - the group - * AtomsMixin.createAtomsGroup() builds for the base (un-repeated) structure. Scoping to it, plus - * the isMesh/isInstancedMesh check below, is what keeps the following out of the basis: - * - bond InstancedMeshes (BondsMixin.createInstancedMeshForBonds): in the installed three.js - * version InstancedMesh does not override the base Object3D "type", so - * `bondMesh.type === "Mesh"` is true and a plain type check cannot tell a bond from an atom - - * hence checking `isMesh && !isInstancedMesh` explicitly instead of `type === "Mesh"`; - * - boundary condition planes (BoundaryMixin.getBoundaryMeshObject): plain Meshes added directly - * to the structure group, never inside an ATOM_GROUP_NAME group; - * - repetition clones (RepetitionMixin.repeatAtomsAtRepetitionCoordinates): each clone is its own - * group also named ATOM_GROUP_NAME, but always added *after* the real one (the base group is - * added first, then every clone), so taking only the first match keeps this to the base - * structure's own atoms. - */ -function extractBasisFromScene(scene, cellVectorsArray) { - const structureGroup = getStructureGroup(scene); - const atomsGroup = structureGroup.children.find((child) => child.name === ATOM_GROUP_NAME); - const elements = []; - const coordinates = []; - if (atomsGroup) { - atomsGroup.traverse((object) => { - if (object.isMesh && !object.isInstancedMesh) { - elements.push(object.name.split("-")[0] || "Si"); - const vector = new THREE.Vector3(); - coordinates.push(object.getWorldPosition(vector).toArray()); - } - }); - } - const newCell = Made.Cell.fromVectorsArray(cellVectorsArray); - return Made.Basis.fromElementsAndCoordinates({ - elements, - coordinates, - units: "cartesian", - cell: newCell, - }); -} -/** - * Converts a given scene data to a material. - * Lattice is constructed from the LineSegments object(s) representing the unit cell. - * Basis is constructed from the base structure's atom sphere meshes (see - * extractBasisFromScene above for what that excludes). - */ -export function ThreeDSceneDataToMaterial(scene) { - const lattice = extractLatticeFromScene(scene); - const basis = extractBasisFromScene(scene, lattice.vectorArrays); - basis.toCrystal(); - return new Made.Material({ - name: scene.getObjectByProperty("type", "Group").name, - lattice: lattice.toJSON(), - basis: basis.toJSON(), - }); -} diff --git a/dist/utils/editActions.d.ts b/dist/utils/editActions.d.ts deleted file mode 100644 index 53899a01..00000000 --- a/dist/utils/editActions.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * A short past-tense description of a committed edit, with the undo binding appended when the edit - * is something undo would reverse. Undo and redo themselves get no such suffix - telling someone - * who just pressed undo that they can press undo is noise. - */ -export declare function describeEditCommit(source?: string | null): string | null; -/** How long a committed-edit hint stays on screen. */ -export declare const EDIT_HINT_TIMEOUT_MS = 4000; diff --git a/dist/utils/editActions.js b/dist/utils/editActions.js deleted file mode 100644 index 938489c6..00000000 --- a/dist/utils/editActions.js +++ /dev/null @@ -1,42 +0,0 @@ -import settings from "../settings"; -import { formatEditorKey } from "./keyBindings"; -/** - * Turns `onEditCommit`'s `{source}` into something a person can read, so the viewer can say what - * it just did and how to take it back. - * - * The enum already exists and is already reported per commit (spec §6.2), so this needs no new - * plumbing - it was simply never surfaced anywhere. Undo was doubly hidden: its buttons rendered - * only inside the edit panel and its hotkey was gated on edit mode, so leaving edit mode made a - * surviving history unreachable (finding F6). - */ -/** `source` values `onEditCommit` reports. */ -const SOURCE_PHRASES = { - drag: "Moved atom", - gizmo: "Moved atom", - "coordinate-input": "Set coordinate", - "element-input": "Changed element", - add: "Added atom", - remove: "Removed atom", - clone: "Cloned atoms", - undo: "Undone", - redo: "Redone", -}; -/** - * A short past-tense description of a committed edit, with the undo binding appended when the edit - * is something undo would reverse. Undo and redo themselves get no such suffix - telling someone - * who just pressed undo that they can press undo is noise. - */ -export function describeEditCommit(source) { - var _a; - if (!source) - return null; - const phrase = SOURCE_PHRASES[source]; - if (!phrase) - return null; - if (source === "undo" || source === "redo") - return phrase; - const undoBinding = (_a = settings.editorKeysConfig) === null || _a === void 0 ? void 0 : _a.undo; - return undoBinding ? `${phrase} · ${formatEditorKey(undoBinding)} to undo` : phrase; -} -/** How long a committed-edit hint stays on screen. */ -export const EDIT_HINT_TIMEOUT_MS = 4000; diff --git a/dist/utils/figureExport.d.ts b/dist/utils/figureExport.d.ts deleted file mode 100644 index 2fb1b011..00000000 --- a/dist/utils/figureExport.d.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Figure export (U-12). - * - * `takeScreenshot` reads the on-screen canvas back with `toDataURL`, which means every image the - * viewer has ever produced is the dark viewer theme at whatever pixel size the container happened - * to have - typically a few hundred pixels tall, and unusable in a paper. The publication case - * needs three things the canvas cannot give: a chosen background, a resolution set in pixels - * rather than inherited from the layout, and a scale bar. - * - * Everything here is pure: sizes, background definitions, the scale-bar step choice and the - * filename. The rendering side lives in mixins/image.js, which is where the renderer is. - */ -/** Dots per inch assumed when reporting a pixel size as a physical one. Journal figures are 300. */ -export declare const FIGURE_DPI = 300; -export type FigureBackgroundId = "viewer" | "white" | "transparent"; -export interface FigureBackground { - id: FigureBackgroundId; - label: string; - /** Renderer clear colour. Still set for a transparent export, so anti-aliased edges blend - * towards the page colour rather than towards black. */ - clearColor: string; - /** 0 writes an alpha channel instead of a background. */ - clearAlpha: number; - /** - * Colour to redraw the viewer's *chrome* in - cell edges, text labels, scale bar - or null to - * leave the scene as it is on screen. The viewer draws chrome light-on-dark (`#CCCCCC` cell - * edges, `#EEEEEE` label text), so exporting onto a white page without inverting it produces a - * figure whose unit cell and labels are simply absent. Atom colours are element identity and - * are never touched. - */ - foregroundColor: string | null; - hint: string; -} -export declare const FIGURE_BACKGROUNDS: FigureBackground[]; -export declare function getFigureBackground(id: string | undefined | null): FigureBackground; -export interface FigureSizePreset { - id: string; - label: string; - /** Target width in pixels; null means "whatever the canvas is right now". */ - width: number | null; - hint: string; -} -/** - * Widths chosen from what a figure is actually for. The two column widths are the near-universal - * single/double column measures (85 mm and 180 mm) at 300 dpi, so the exported file needs no - * resampling on the way into a manuscript. - */ -export declare const FIGURE_SIZE_PRESETS: FigureSizePreset[]; -export declare function getFigureSizePreset(id: string | undefined | null): FigureSizePreset; -/** Smallest useful figure. Below this the scale bar and labels stop being legible anyway. */ -export declare const MIN_FIGURE_DIMENSION = 64; -/** - * Fallback cap when the GL context cannot be asked for its own limit. WebGL implementations are - * required to support at least 2048; 8192 is what current desktop drivers report, and asking for - * more than the driver allows fails the render rather than producing a large image. - */ -export declare const DEFAULT_MAX_FIGURE_DIMENSION = 8192; -/** - * Side of the square the rotating GIF is rendered at. - * - * Fixed rather than derived from the canvas: a GIF that inherits the window's aspect ratio comes out - * a different shape on every machine, and letterboxed wherever it is embedded. A rotating structure - * also *wants* a square - it sweeps through its own width as it turns, so the frame has to hold the - * structure's largest dimension in both axes or the animation clips at the extremes. - * - * 512 is smaller in area than the window-sized frames it replaces on a typical desktop, so encoding - * gets cheaper as well as more predictable. - */ -export declare const DEFAULT_GIF_SIDE_PX = 512; -/** Square side for a GIF: the requested size, or the default, clamped to what the context allows. */ -export declare function getGifSide({ requested, maxDimension, }?: { - requested?: number | null; - maxDimension?: number; -}): number; -export interface FigureResolution { - width: number; - height: number; - /** True when the request exceeded `maxDimension` and was scaled down to fit. */ - isClamped: boolean; -} -interface ResolutionRequest { - presetId?: string | null; - customWidth?: number | null; - customHeight?: number | null; - viewportWidth: number; - viewportHeight: number; - maxDimension?: number; -} -/** - * Resolves the requested size to real pixel dimensions. - * - * Height follows from the canvas aspect ratio for every preset, so choosing a publication width - * cannot silently squash the structure - the one thing a figure export must not do. Only the - * custom preset lets both dimensions be set, because there the distortion is the user's choice. - */ -export declare function getFigureResolution({ presetId, customWidth, customHeight, viewportWidth, viewportHeight, maxDimension, }: ResolutionRequest): FigureResolution; -/** Pixel size expressed in millimetres at `dpi`, rounded to whole millimetres. */ -export declare function getFigurePhysicalSize({ width, height }: { - width: number; - height: number; -}, dpi?: number): { - widthMm: number; - heightMm: number; - dpi: number; -}; -/** One line stating exactly what will be written, in both pixels and millimetres. */ -export declare function describeFigureResolution(resolution: { - width: number; - height: number; -}): string; -export interface ScaleBarPlan { - /** Bar length in the structure's own units (Ångström). */ - lengthAngstrom: number; - /** Bar length in image pixels. */ - lengthPx: number; - label: string; -} -/** - * Chooses a scale-bar length: a round number of Ångström whose drawn length is near - * `targetFraction` of the image width. - * - * Returns null when there is nothing honest to draw - a non-finite or non-positive scale, or a bar - * that would run off the image. A wrong scale bar is worse than none, so this never guesses. - */ -export declare function getScaleBarPlan({ worldUnitsPerPixel, imageWidth, targetFraction, }: { - worldUnitsPerPixel: number; - imageWidth: number; - targetFraction?: number; -}): ScaleBarPlan | null; -/** - * Geometry of the scale bar within the image, in pixels. Split out from the drawing so the layout - * can be asserted without a 2D canvas, which jsdom does not provide. - */ -export declare function getScaleBarLayout({ plan, width, height, }: { - plan: ScaleBarPlan; - width: number; - height: number; -}): { - margin: number; - barHeight: number; - fontSize: number; - barX: number; - barY: number; - barWidth: number; - labelX: number; - labelBaselineY: number; -}; -type MinimalContext2D = Pick & { - fillStyle: string | CanvasGradient | CanvasPattern; - font: string; - textBaseline: CanvasTextBaseline; - textAlign: CanvasTextAlign; -}; -/** - * Draws the bar and its label into an already-composited 2D context. - * `color` must contrast with the chosen background - see `FigureBackground.foregroundColor`. - */ -export declare function drawScaleBar(context: MinimalContext2D, plan: ScaleBarPlan, { width, height, color }: { - width: number; - height: number; - color: string; -}): { - margin: number; - barHeight: number; - fontSize: number; - barX: number; - barY: number; - barWidth: number; - labelX: number; - labelBaselineY: number; -}; -/** - * Names the file after the structure and the size it was rendered at, so a folder of exports at - * different resolutions stays tellable apart without opening them. - */ -export declare function getFigureFileName({ name, formula, width, height, backgroundId, }: { - name?: string | null; - formula?: string | null; - width: number; - height: number; - backgroundId?: string | null; -}): string; -export {}; diff --git a/dist/utils/figureExport.js b/dist/utils/figureExport.js deleted file mode 100644 index bd4a3722..00000000 --- a/dist/utils/figureExport.js +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Figure export (U-12). - * - * `takeScreenshot` reads the on-screen canvas back with `toDataURL`, which means every image the - * viewer has ever produced is the dark viewer theme at whatever pixel size the container happened - * to have - typically a few hundred pixels tall, and unusable in a paper. The publication case - * needs three things the canvas cannot give: a chosen background, a resolution set in pixels - * rather than inherited from the layout, and a scale bar. - * - * Everything here is pure: sizes, background definitions, the scale-bar step choice and the - * filename. The rendering side lives in mixins/image.js, which is where the renderer is. - */ -/** Dots per inch assumed when reporting a pixel size as a physical one. Journal figures are 300. */ -export const FIGURE_DPI = 300; -const MM_PER_INCH = 25.4; -export const FIGURE_BACKGROUNDS = [ - { - id: "viewer", - label: "Viewer (dark)", - clearColor: "#202020", - clearAlpha: 1, - foregroundColor: null, - hint: "What the viewer shows now — for slides and dark documents.", - }, - { - id: "white", - label: "White", - clearColor: "#FFFFFF", - clearAlpha: 1, - foregroundColor: "#1A1A1A", - hint: "Cell edges and labels are redrawn dark so they survive on a white page.", - }, - { - id: "transparent", - label: "Transparent", - clearColor: "#FFFFFF", - clearAlpha: 0, - // Chrome is drawn dark on the assumption the figure lands on a light page, which is what - // a transparent PNG is almost always for. Stated in the hint rather than left to surprise. - foregroundColor: "#1A1A1A", - hint: "Alpha channel instead of a background. Chrome is drawn dark, for a light page.", - }, -]; -export function getFigureBackground(id) { - return FIGURE_BACKGROUNDS.find((background) => background.id === id) || FIGURE_BACKGROUNDS[0]; -} -/** - * Widths chosen from what a figure is actually for. The two column widths are the near-universal - * single/double column measures (85 mm and 180 mm) at 300 dpi, so the exported file needs no - * resampling on the way into a manuscript. - */ -export const FIGURE_SIZE_PRESETS = [ - { - id: "viewport", - label: "On-screen", - width: null, - hint: "Same pixels as the canvas — the old screenshot behaviour.", - }, - { - id: "single-column", - label: "Single column", - width: Math.round((85 / MM_PER_INCH) * FIGURE_DPI), - hint: "85 mm at 300 dpi.", - }, - { - id: "double-column", - label: "Double column", - width: Math.round((180 / MM_PER_INCH) * FIGURE_DPI), - hint: "180 mm at 300 dpi.", - }, - { - id: "slide", - label: "Slide", - width: 1920, - hint: "1920 px wide, for a presentation.", - }, - { - id: "custom", - label: "Custom", - width: null, - hint: "Set the pixel size directly.", - }, -]; -export function getFigureSizePreset(id) { - return FIGURE_SIZE_PRESETS.find((preset) => preset.id === id) || FIGURE_SIZE_PRESETS[0]; -} -/** Smallest useful figure. Below this the scale bar and labels stop being legible anyway. */ -export const MIN_FIGURE_DIMENSION = 64; -/** - * Fallback cap when the GL context cannot be asked for its own limit. WebGL implementations are - * required to support at least 2048; 8192 is what current desktop drivers report, and asking for - * more than the driver allows fails the render rather than producing a large image. - */ -export const DEFAULT_MAX_FIGURE_DIMENSION = 8192; -/** - * Side of the square the rotating GIF is rendered at. - * - * Fixed rather than derived from the canvas: a GIF that inherits the window's aspect ratio comes out - * a different shape on every machine, and letterboxed wherever it is embedded. A rotating structure - * also *wants* a square - it sweeps through its own width as it turns, so the frame has to hold the - * structure's largest dimension in both axes or the animation clips at the extremes. - * - * 512 is smaller in area than the window-sized frames it replaces on a typical desktop, so encoding - * gets cheaper as well as more predictable. - */ -export const DEFAULT_GIF_SIDE_PX = 512; -/** Square side for a GIF: the requested size, or the default, clamped to what the context allows. */ -export function getGifSide({ requested, maxDimension = DEFAULT_MAX_FIGURE_DIMENSION, } = {}) { - const side = Number(requested) > 0 ? Number(requested) : DEFAULT_GIF_SIDE_PX; - return Math.min(Math.max(Math.round(side), MIN_FIGURE_DIMENSION), Math.round(maxDimension)); -} -function clampToRange(value, max) { - return Math.min(Math.max(Math.round(value), MIN_FIGURE_DIMENSION), max); -} -/** - * Resolves the requested size to real pixel dimensions. - * - * Height follows from the canvas aspect ratio for every preset, so choosing a publication width - * cannot silently squash the structure - the one thing a figure export must not do. Only the - * custom preset lets both dimensions be set, because there the distortion is the user's choice. - */ -export function getFigureResolution({ presetId, customWidth, customHeight, viewportWidth, viewportHeight, maxDimension = DEFAULT_MAX_FIGURE_DIMENSION, }) { - const max = Math.max(MIN_FIGURE_DIMENSION, Math.round(maxDimension)); - const hasViewport = viewportWidth > 0 && viewportHeight > 0; - const aspect = hasViewport ? viewportWidth / viewportHeight : 4 / 3; - const preset = getFigureSizePreset(presetId); - let width; - let height; - if (preset.id === "custom") { - width = Number(customWidth) > 0 ? Number(customWidth) : Math.round(720 * aspect); - height = Number(customHeight) > 0 ? Number(customHeight) : 720; - } - else if (preset.width) { - width = preset.width; - height = preset.width / aspect; - } - else { - width = hasViewport ? viewportWidth : Math.round(720 * aspect); - height = hasViewport ? viewportHeight : 720; - } - // Scale both dimensions by the same factor when over the limit: clamping them independently - // would change the aspect ratio, which is the distortion the whole function avoids. - const overshoot = Math.max(width / max, height / max, 1); - const isClamped = overshoot > 1; - return { - width: clampToRange(width / overshoot, max), - height: clampToRange(height / overshoot, max), - isClamped, - }; -} -/** Pixel size expressed in millimetres at `dpi`, rounded to whole millimetres. */ -export function getFigurePhysicalSize({ width, height }, dpi = FIGURE_DPI) { - return { - widthMm: Math.round((width / dpi) * MM_PER_INCH), - heightMm: Math.round((height / dpi) * MM_PER_INCH), - dpi, - }; -} -/** One line stating exactly what will be written, in both pixels and millimetres. */ -export function describeFigureResolution(resolution) { - const { widthMm, heightMm, dpi } = getFigurePhysicalSize(resolution); - return `${resolution.width} × ${resolution.height} px · ${widthMm} × ${heightMm} mm at ${dpi} dpi`; -} -/** 1-2-5 progression, so the bar always reads as a round number rather than "8.37 Å". */ -const NICE_MANTISSAS = [1, 2, 5]; -function niceLengthNear(target) { - const exponent = Math.floor(Math.log10(target)); - // One decade either side, so a target just below a decade boundary can still pick the round - // number above it. Compared in log space: "nearest" for a scale bar means nearest by ratio, or - // 5 would always beat 10 for a target of 7. - const candidates = [exponent - 1, exponent, exponent + 1].flatMap((e) => NICE_MANTISSAS.map((mantissa) => mantissa * 10 ** e)); - return candidates.reduce((best, candidate) => Math.abs(Math.log(candidate / target)) < Math.abs(Math.log(best / target)) - ? candidate - : best); -} -function formatAngstrom(length) { - // Strip the trailing zeros a fixed precision would leave: "0.5 Å", "2 Å", "20 Å". - const text = length >= 1 ? String(Number(length.toFixed(2))) : String(Number(length.toFixed(3))); - return `${text} Å`; -} -/** - * Chooses a scale-bar length: a round number of Ångström whose drawn length is near - * `targetFraction` of the image width. - * - * Returns null when there is nothing honest to draw - a non-finite or non-positive scale, or a bar - * that would run off the image. A wrong scale bar is worse than none, so this never guesses. - */ -export function getScaleBarPlan({ worldUnitsPerPixel, imageWidth, targetFraction = 0.18, }) { - if (!Number.isFinite(worldUnitsPerPixel) || worldUnitsPerPixel <= 0) - return null; - if (!Number.isFinite(imageWidth) || imageWidth < MIN_FIGURE_DIMENSION) - return null; - const targetWorld = worldUnitsPerPixel * imageWidth * targetFraction; - if (!Number.isFinite(targetWorld) || targetWorld <= 0) - return null; - const lengthAngstrom = niceLengthNear(targetWorld); - const lengthPx = lengthAngstrom / worldUnitsPerPixel; - if (!Number.isFinite(lengthPx) || lengthPx < 8 || lengthPx > imageWidth * 0.8) - return null; - return { lengthAngstrom, lengthPx, label: formatAngstrom(lengthAngstrom) }; -} -/** - * Geometry of the scale bar within the image, in pixels. Split out from the drawing so the layout - * can be asserted without a 2D canvas, which jsdom does not provide. - */ -export function getScaleBarLayout({ plan, width, height, }) { - const margin = Math.max(8, Math.round(height * 0.045)); - const barHeight = Math.max(2, Math.round(height / 160)); - // height/28 puts the label at roughly 7 pt once the figure is placed at 300 dpi. A smaller - // fraction looks fine on screen and comes out below most journals' minimum type size in print, - // which is the one place this annotation has to survive. - const fontSize = Math.max(10, Math.round(height / 28)); - const gap = Math.max(2, Math.round(fontSize * 0.35)); - return { - margin, - barHeight, - fontSize, - barX: margin, - barY: height - margin - barHeight, - barWidth: Math.min(plan.lengthPx, width - 2 * margin), - labelX: margin, - labelBaselineY: height - margin - barHeight - gap, - }; -} -/** - * Draws the bar and its label into an already-composited 2D context. - * `color` must contrast with the chosen background - see `FigureBackground.foregroundColor`. - */ -export function drawScaleBar(context, plan, { width, height, color }) { - const layout = getScaleBarLayout({ plan, width, height }); - context.save(); - context.fillStyle = color; - context.fillRect(layout.barX, layout.barY, layout.barWidth, layout.barHeight); - context.font = `${layout.fontSize}px Arial, Helvetica, sans-serif`; - context.textAlign = "left"; - context.textBaseline = "alphabetic"; - context.fillText(plan.label, layout.labelX, layout.labelBaselineY); - context.restore(); - return layout; -} -/** - * Filesystem-safe basename. A structure name is free text that reaches a download filename, so a - * name like "../../Si/Ge (001)" must come out as a plain name: separators collapse to hyphens, runs - * of dots collapse to one (no ".." component), and leading punctuation is dropped so the result is - * never a dotfile. - */ -function slugify(name) { - return (name - .trim() - .replace(/[^A-Za-z0-9._-]+/g, "-") - .replace(/\.{2,}/g, ".") - .replace(/^[-._]+|[-._]+$/g, "") - .slice(0, 80) || "structure"); -} -/** - * Names the file after the structure and the size it was rendered at, so a folder of exports at - * different resolutions stays tellable apart without opening them. - */ -export function getFigureFileName({ name, formula, width, height, backgroundId, }) { - const base = slugify(name || formula || "wave-figure"); - const background = getFigureBackground(backgroundId); - const suffix = background.id === "viewer" ? "" : `-${background.id}`; - return `${base}${suffix}-${width}x${height}.png`; -} diff --git a/dist/utils/inputCapabilities.d.ts b/dist/utils/inputCapabilities.d.ts deleted file mode 100644 index 65dc48e4..00000000 --- a/dist/utils/inputCapabilities.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * What kind of pointer is driving the viewer (U-13). - * - * `IconsToolbar` computed `isMobile` from `theme.breakpoints.down("sm")` and forwarded it to exactly - * one dropdown; nothing else in the viewer adapted. That is the half-support the proposal called the - * worst of the three options, and it asked the wrong question twice over: a narrow browser window on - * a desktop is not touch input, and a 13-inch touch laptop is, at any width. What actually decides - * whether a 32 px control is reachable, whether a hover tooltip can ever be seen, and whether "right - * button" names anything real is the *pointer*, not the viewport. - * - * So capability queries, read at call time rather than at module load, because a window can move to - * another display and a tablet can gain a keyboard mid-session. - */ -/** Devices whose primary pointer has coarse accuracy: fingers, and styluses without hover. */ -export declare const COARSE_POINTER_QUERY = "(pointer: coarse)"; -/** Devices that cannot hover, so anything living only in a tooltip is unreachable. */ -export declare const NO_HOVER_QUERY = "(hover: none)"; -/** - * Minimum comfortable touch target. 44 px is the long-standing platform guidance (WCAG 2.2's - * Target Size (Minimum) sets 24 px as the floor; 44 is what a finger actually wants). - */ -export declare const TOUCH_TARGET_MIN_PX = 44; -export declare function hasCoarsePointer(): boolean; -export declare function hasNoHover(): boolean; -/** - * Whether the device can produce touch input at all - which is not the same question as whether the - * *primary* pointer is coarse. A touch laptop reports a fine primary pointer and still needs the - * touch gestures documented, so this is what gates the gesture list while `hasCoarsePointer` gates - * sizing. - */ -export declare function hasTouchSupport(): boolean; -/** - * `sx` fragment enlarging a control to the touch minimum on coarse pointers only, so a mouse-driven - * viewer keeps its compact chrome. Applied as a media query rather than a breakpoint for the reason - * in the module comment. - */ -export declare const coarsePointerTargetSx: { - "@media (pointer: coarse)": { - minWidth: string; - minHeight: string; - }; -}; diff --git a/dist/utils/inputCapabilities.js b/dist/utils/inputCapabilities.js deleted file mode 100644 index 4be3d006..00000000 --- a/dist/utils/inputCapabilities.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * What kind of pointer is driving the viewer (U-13). - * - * `IconsToolbar` computed `isMobile` from `theme.breakpoints.down("sm")` and forwarded it to exactly - * one dropdown; nothing else in the viewer adapted. That is the half-support the proposal called the - * worst of the three options, and it asked the wrong question twice over: a narrow browser window on - * a desktop is not touch input, and a 13-inch touch laptop is, at any width. What actually decides - * whether a 32 px control is reachable, whether a hover tooltip can ever be seen, and whether "right - * button" names anything real is the *pointer*, not the viewport. - * - * So capability queries, read at call time rather than at module load, because a window can move to - * another display and a tablet can gain a keyboard mid-session. - */ -/** Devices whose primary pointer has coarse accuracy: fingers, and styluses without hover. */ -export const COARSE_POINTER_QUERY = "(pointer: coarse)"; -/** Devices that cannot hover, so anything living only in a tooltip is unreachable. */ -export const NO_HOVER_QUERY = "(hover: none)"; -/** - * Minimum comfortable touch target. 44 px is the long-standing platform guidance (WCAG 2.2's - * Target Size (Minimum) sets 24 px as the floor; 44 is what a finger actually wants). - */ -export const TOUCH_TARGET_MIN_PX = 44; -function matches(query) { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") - return false; - try { - return window.matchMedia(query).matches; - } - catch (error) { - // jsdom and some embedded webviews implement matchMedia partially; an unsupported query - // should read as "not coarse" rather than take the viewer down. - return false; - } -} -export function hasCoarsePointer() { - return matches(COARSE_POINTER_QUERY); -} -export function hasNoHover() { - return matches(NO_HOVER_QUERY); -} -/** - * Whether the device can produce touch input at all - which is not the same question as whether the - * *primary* pointer is coarse. A touch laptop reports a fine primary pointer and still needs the - * touch gestures documented, so this is what gates the gesture list while `hasCoarsePointer` gates - * sizing. - */ -export function hasTouchSupport() { - if (typeof navigator !== "undefined" && typeof navigator.maxTouchPoints === "number") { - return navigator.maxTouchPoints > 0; - } - if (typeof window !== "undefined" && "ontouchstart" in window) - return true; - return hasCoarsePointer(); -} -/** - * `sx` fragment enlarging a control to the touch minimum on coarse pointers only, so a mouse-driven - * viewer keeps its compact chrome. Applied as a media query rather than a breakpoint for the reason - * in the module comment. - */ -export const coarsePointerTargetSx = { - [`@media ${COARSE_POINTER_QUERY}`]: { - minWidth: `${TOUCH_TARGET_MIN_PX}px`, - minHeight: `${TOUCH_TARGET_MIN_PX}px`, - }, -}; diff --git a/dist/utils/keyBindings.d.ts b/dist/utils/keyBindings.d.ts deleted file mode 100644 index 0fb64261..00000000 --- a/dist/utils/keyBindings.d.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * One source of truth for what the viewer's keys and pointer gestures do. - * - * Twelve configured keys plus nine hard-coded pointer and key bindings existed, and ten of the - * twenty-one appeared in no tooltip and no menu (finding F3) - every selection modifier, the - * right-button orbit remap, Delete, and undo itself. The fastest paths in the editor were the - * least discoverable ones. - * - * Generating the sheet from here means a rebind updates the label with it. Defect D2 - a tooltip - * that promised a hotkey which did not exist - was the same drift running the other way. - */ -export type BindingGroup = "view" | "edit" | "measure" | "touch"; -export interface KeyBinding { - /** What the binding does, in the user's terms. */ - label: string; - /** The keys or gesture, already formatted for display. */ - keys: string; - group: BindingGroup; - /** A pointer gesture rather than a key - grouped the same, rendered the same. */ - isGesture?: boolean; - /** - * Only meaningful in edit mode. The mouse gestures express this through `group: "edit"`, but the - * touch rows are grouped by input device instead, so they carry it explicitly - filtering them by - * matching label text would break silently the first time a label is reworded. - */ - editOnly?: boolean; -} -export interface EditorKeyDefinition { - keys: string[]; - usesModifier?: boolean; - requiresShift?: boolean; - label?: string; -} -/** True when `event` matches the given editor key definition. */ -export declare function matchesEditorKey(event: Pick, definition?: EditorKeyDefinition | null): boolean; -/** - * Cmd on macOS, Ctrl elsewhere. Read at call time rather than at module load so a test can vary - * the platform, and guarded because this also runs where `navigator` is absent. - */ -export declare function getModifierLabel(): string; -/** "Ctrl + Shift + Z" for a definition, using the running platform's modifier name. */ -export declare function formatEditorKey(definition: EditorKeyDefinition): string; -export declare const BINDING_GROUP_LABELS: Record; -/** - * Every binding, grouped. `editable` mirrors the component prop: without it there is no edit mode, - * so advertising its keys would promise something the viewer will not do. - * - * `includeTouch` defaults to whether the device can produce touch input at all, rather than to - * whether the primary pointer is coarse: a touch laptop drives the viewer with a trackpad and still - * needs the gestures documented. Keyboard rows stay visible either way - a tablet with a keyboard is - * an ordinary configuration, and hiding them would be the mirror image of the F3 discoverability - * problem this file exists to fix. - */ -export declare function getKeyBindings({ editable, includeTouch, }?: { - editable?: boolean; - includeTouch?: boolean; -}): KeyBinding[]; -/** - * The same bindings bucketed by group, skipping groups that came out empty. - * - * `touchFirst` moves the touch group to the front. On a phone the sheet reflows to a single column, - * so a group's position is how far the user has to scroll to reach it - and putting the gestures that - * are the only way to drive that device below three groups of keyboard shortcuts buries the one - * section they came for. - */ -export declare function getGroupedKeyBindings(options?: { - editable?: boolean; - includeTouch?: boolean; - touchFirst?: boolean; -}): { - group: BindingGroup; - label: string; - bindings: KeyBinding[]; -}[]; diff --git a/dist/utils/keyBindings.js b/dist/utils/keyBindings.js deleted file mode 100644 index d45f42ea..00000000 --- a/dist/utils/keyBindings.js +++ /dev/null @@ -1,174 +0,0 @@ -import settings from "../settings"; -import { hasTouchSupport } from "./inputCapabilities"; -/** True when `event` matches the given editor key definition. */ -export function matchesEditorKey(event, definition) { - var _a; - if (!((_a = definition === null || definition === void 0 ? void 0 : definition.keys) === null || _a === void 0 ? void 0 : _a.length)) - return false; - const hasModifier = Boolean(event.metaKey || event.ctrlKey); - if (Boolean(definition.usesModifier) !== hasModifier) - return false; - // Shift is only significant for combos that use a modifier; Delete-with-Shift is still Delete. - if (definition.usesModifier && Boolean(definition.requiresShift) !== Boolean(event.shiftKey)) { - return false; - } - return definition.keys.some((key) => { var _a; return key.toLowerCase() === ((_a = event.key) === null || _a === void 0 ? void 0 : _a.toLowerCase()); }); -} -/** - * Cmd on macOS, Ctrl elsewhere. Read at call time rather than at module load so a test can vary - * the platform, and guarded because this also runs where `navigator` is absent. - */ -export function getModifierLabel() { - const platform = typeof navigator === "undefined" - ? "" - : navigator.platform || navigator.userAgent || ""; - return /Mac|iPhone|iPad/i.test(platform) ? "Cmd" : "Ctrl"; -} -/** "Ctrl + Shift + Z" for a definition, using the running platform's modifier name. */ -export function formatEditorKey(definition) { - const parts = []; - if (definition.usesModifier) - parts.push(getModifierLabel()); - if (definition.requiresShift) - parts.push("Shift"); - const keys = definition.keys.map((key) => (key.length === 1 ? key.toUpperCase() : key)); - // Alternatives ("Delete / Backspace") rather than a combo, so they are joined differently. - parts.push(keys.join(" / ")); - return parts.join(" + "); -} -/** - * Pointer gestures. They are not in any config because there is nothing to rebind, but leaving - * them out is why "Shift-click adds to the selection" was documented nowhere in the product. - */ -const POINTER_GESTURES = [ - { label: "Select atom", keys: "click", group: "edit", isGesture: true }, - { label: "Add to selection", keys: "Shift + click", group: "edit", isGesture: true }, - { label: "Toggle one atom", keys: "Cmd/Ctrl + click", group: "edit", isGesture: true }, - { label: "Marquee select", keys: "drag empty space", group: "edit", isGesture: true }, - // The remap that catches people out: a left-drag marquees, so orbit moves to the right button. - { label: "Orbit while editing", keys: "right-drag", group: "edit", isGesture: true }, - { label: "Move atom / group", keys: "drag atom", group: "edit", isGesture: true }, -]; -/** - * Touch gestures (U-13). Listed separately because they are not alternative labels for the mouse - * gestures above - the mapping genuinely differs. In edit mode one finger is reserved for atoms, the - * same way the left button is (decision D-4), so orbiting moves to two fingers rather than the right - * button, which touch does not have. - * - * Every row here has to be true of the running build: this list existing at all is only an - * improvement if a user who follows it gets the result it promises. - */ -const TOUCH_GESTURES = [ - { - label: "Rotate (once Rotate/Zoom is on)", - keys: "one-finger drag", - group: "touch", - isGesture: true, - }, - { label: "Zoom", keys: "pinch", group: "touch", isGesture: true }, - { label: "Pan", keys: "two-finger drag", group: "touch", isGesture: true }, - { label: "Select atom", keys: "tap", group: "touch", isGesture: true, editOnly: true }, - { - label: "Move atom / group", - keys: "drag atom", - group: "touch", - isGesture: true, - editOnly: true, - }, - { - label: "Orbit while editing", - keys: "two fingers", - group: "touch", - isGesture: true, - editOnly: true, - }, -]; -/** Configured single-character keys, in the order they should read, with their group. */ -const HOTKEY_ROWS = [ - { setting: "toggleInteractive", label: "Interactive on / off", group: "view" }, - { setting: "toggleOrbitControls", label: "Rotate / zoom", group: "view" }, - { setting: "toggleBonds", label: "Bonds", group: "view" }, - { setting: "toggleElementLabels", label: "Element labels", group: "view" }, - { setting: "toggleCoordinateLabels", label: "Coordinate labels", group: "view" }, - { setting: "resetViewer", label: "Reset view", group: "view" }, - { setting: "toggleKeyboardSheet", label: "This sheet", group: "view" }, - { setting: "toggleEditMode", label: "Edit mode", group: "edit" }, - { setting: "focusCameraOnSelection", label: "Focus on selection", group: "edit" }, - { setting: "toggleDistanceShown", label: "Distances", group: "measure" }, - { setting: "toggleAnglesShown", label: "Angles", group: "measure" }, - { setting: "toggleCopyCoordinatesShown", label: "Copy coordinates", group: "measure" }, - { setting: "deleteConnection", label: "Clear last connection", group: "measure" }, -]; -/** Which editor keys belong in which group, in display order. */ -const EDITOR_KEY_GROUPS = [ - { setting: "cancelOrDeselect", group: "edit" }, - { setting: "removeSelected", group: "edit" }, - { setting: "undo", group: "edit" }, - { setting: "redo", group: "edit" }, -]; -export const BINDING_GROUP_LABELS = { - view: "View & camera", - edit: "Select & edit", - measure: "Measure", - touch: "Touch", -}; -/** - * Every binding, grouped. `editable` mirrors the component prop: without it there is no edit mode, - * so advertising its keys would promise something the viewer will not do. - * - * `includeTouch` defaults to whether the device can produce touch input at all, rather than to - * whether the primary pointer is coarse: a touch laptop drives the viewer with a trackpad and still - * needs the gestures documented. Keyboard rows stay visible either way - a tablet with a keyboard is - * an ordinary configuration, and hiding them would be the mirror image of the F3 discoverability - * problem this file exists to fix. - */ -export function getKeyBindings({ editable = true, includeTouch, } = {}) { - const hotKeys = settings.hotKeysConfig; - const editorKeys = settings.editorKeysConfig; - const fromHotKeys = HOTKEY_ROWS.filter((row) => hotKeys[row.setting] && (editable || row.group !== "edit")).map((row) => ({ - label: row.label, - keys: hotKeys[row.setting].length === 1 - ? hotKeys[row.setting].toUpperCase() - : hotKeys[row.setting], - group: row.group, - })); - const fromEditorKeys = editable - ? EDITOR_KEY_GROUPS.filter((row) => editorKeys[row.setting]).map((row) => { - const definition = editorKeys[row.setting]; - return { - label: definition.label || row.setting, - keys: formatEditorKey(definition), - group: row.group, - }; - }) - : []; - const gestures = editable - ? POINTER_GESTURES - : POINTER_GESTURES.filter((gesture) => gesture.group !== "edit"); - const showTouch = includeTouch !== null && includeTouch !== void 0 ? includeTouch : hasTouchSupport(); - const touchGestures = showTouch - ? TOUCH_GESTURES.filter((gesture) => editable || !gesture.editOnly) - : []; - return [...fromHotKeys, ...fromEditorKeys, ...gestures, ...touchGestures]; -} -/** - * The same bindings bucketed by group, skipping groups that came out empty. - * - * `touchFirst` moves the touch group to the front. On a phone the sheet reflows to a single column, - * so a group's position is how far the user has to scroll to reach it - and putting the gestures that - * are the only way to drive that device below three groups of keyboard shortcuts buries the one - * section they came for. - */ -export function getGroupedKeyBindings(options = {}) { - const all = getKeyBindings(options); - const order = options.touchFirst - ? ["touch", "view", "edit", "measure"] - : ["view", "edit", "measure", "touch"]; - return order - .map((group) => ({ - group, - label: BINDING_GROUP_LABELS[group], - bindings: all.filter((binding) => binding.group === group), - })) - .filter((section) => section.bindings.length > 0); -} diff --git a/dist/utils/measurementReadout.d.ts b/dist/utils/measurementReadout.d.ts deleted file mode 100644 index ac2e26d5..00000000 --- a/dist/utils/measurementReadout.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { MEASUREMENT_MODES_ENUM } from "../enums"; -import { MeasurementSettingsForType } from "../mixins/measurements/MeasurementSettingsHandler"; -/** - * Turns the measurement state the managers already report into something displayable. - * - * A measurement result used to exist only as a 3D sprite plus a silent clipboard write, and - * arming a mode left no trace once its menu closed (finding F5). Both the mode pill and the - * status bar need the same two answers - what has been measured, and how many picks are still - * outstanding - so neither component owns this. - */ -/** Human-facing name of a mode, short enough for a pill badge. */ -export declare function getMeasurementLabel(measurementType?: MEASUREMENT_MODES_ENUM | string): string; -/** What the mode does with a click, for the pill's hint text. */ -export declare function getMeasurementHint(measurement?: MeasurementSettingsForType | null): string; -export interface MeasurementProgress { - /** Picks made toward the measurement in progress, 0 when nothing is half-specified. */ - picked: number; - /** Picks one measurement consumes. */ - needed: number; - /** True when a measurement is part-specified and waiting on more clicks. */ - isPartial: boolean; -} -/** - * Progress toward the *next* measurement. The managers keep every pick in one flat list and - * group it into pairs or triplets, so the remainder is what is outstanding: 5 picks in distance - * mode means two finished pairs and one atom waiting for its partner. - */ -export declare function getMeasurementProgress(measurement?: MeasurementSettingsForType | null): MeasurementProgress; -/** - * The most recent measurement, formatted for a one-line readout, or null when there is none. - * Distances and angles arrive as flat number lists; coordinate mode reports position triples. - */ -export declare function formatMeasurementValue(measurement?: MeasurementSettingsForType | null): string | null; diff --git a/dist/utils/measurementReadout.js b/dist/utils/measurementReadout.js deleted file mode 100644 index 00bfa93e..00000000 --- a/dist/utils/measurementReadout.js +++ /dev/null @@ -1,67 +0,0 @@ -import { MEASUREMENT_MODES } from "../enums"; -import settings from "../settings"; -/** - * Turns the measurement state the managers already report into something displayable. - * - * A measurement result used to exist only as a 3D sprite plus a silent clipboard write, and - * arming a mode left no trace once its menu closed (finding F5). Both the mode pill and the - * status bar need the same two answers - what has been measured, and how many picks are still - * outstanding - so neither component owns this. - */ -/** Human-facing name of a mode, short enough for a pill badge. */ -export function getMeasurementLabel(measurementType) { - switch (measurementType) { - case MEASUREMENT_MODES.DISTANCE: - return "Distance"; - case MEASUREMENT_MODES.ANGLE: - return "Angle"; - case MEASUREMENT_MODES.COORDINATE: - return "Coordinates"; - default: - return ""; - } -} -/** What the mode does with a click, for the pill's hint text. */ -export function getMeasurementHint(measurement) { - if (!measurement) - return ""; - const needed = measurement.atomsPerMeasurement || 1; - if (measurement.measurementType === MEASUREMENT_MODES.COORDINATE) { - return "every atom you click is copied to the clipboard"; - } - const nouns = { 2: "two atoms", 3: "three atoms" }; - return `click ${nouns[needed] || `${needed} atoms`} · result copied to the clipboard`; -} -/** - * Progress toward the *next* measurement. The managers keep every pick in one flat list and - * group it into pairs or triplets, so the remainder is what is outstanding: 5 picks in distance - * mode means two finished pairs and one atom waiting for its partner. - */ -export function getMeasurementProgress(measurement) { - const needed = Math.max(1, (measurement === null || measurement === void 0 ? void 0 : measurement.atomsPerMeasurement) || 1); - const count = (measurement === null || measurement === void 0 ? void 0 : measurement.selectedAtomsCount) || 0; - const picked = needed > 1 ? count % needed : 0; - return { picked, needed, isPartial: picked > 0 }; -} -const round = (value) => value.toFixed(settings.roundPrecision); -/** - * The most recent measurement, formatted for a one-line readout, or null when there is none. - * Distances and angles arrive as flat number lists; coordinate mode reports position triples. - */ -export function formatMeasurementValue(measurement) { - const values = measurement === null || measurement === void 0 ? void 0 : measurement.values; - if (!Array.isArray(values) || !values.length) - return null; - const latest = values[values.length - 1]; - if ((measurement === null || measurement === void 0 ? void 0 : measurement.measurementType) === MEASUREMENT_MODES.COORDINATE) { - if (!Array.isArray(latest)) - return null; - return `(${latest.map((component) => round(component)).join(", ")})`; - } - if (typeof latest !== "number" || Number.isNaN(latest)) - return null; - if ((measurement === null || measurement === void 0 ? void 0 : measurement.measurementType) === MEASUREMENT_MODES.ANGLE) { - return `${round(latest)}°`; - } - return `d = ${round(latest)} Å`; -} diff --git a/dist/utils/useObservedWidth.d.ts b/dist/utils/useObservedWidth.d.ts deleted file mode 100644 index 94ab9300..00000000 --- a/dist/utils/useObservedWidth.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Observes an element's own width. - * - * The viewer is embedded in host applications, where the panel it lives in can be narrow inside a - * perfectly wide window - so `useMediaQuery` and `theme.breakpoints` are the wrong instrument for - * "is there room for this". They answer a question about the *window*, which is the same category of - * mistake as the viewport-width `isMobile` that U-13 removed. A ResizeObserver on the element is a - * real container query: it reports the space the component actually has. - * - * Returns null until the first measurement, so a caller can tell "not measured yet" from "zero - * wide" and avoid deciding a layout on a number it does not have. - * - * `ref` is deliberately a **callback ref**, not a `useRef` object. Consumers here render nothing at - * all much of the time - the mode pill returns null until a mode is armed - and with an object ref - * plus a mount-time effect the observer attaches on the render where the node does not exist yet and - * never re-attaches when it appears. A callback ref fires on every attach and detach. - */ -export declare function useObservedWidth(): { - ref: (node: T | null) => void; - width: number | null; -}; -export default useObservedWidth; diff --git a/dist/utils/useObservedWidth.js b/dist/utils/useObservedWidth.js deleted file mode 100644 index 5aab51c1..00000000 --- a/dist/utils/useObservedWidth.js +++ /dev/null @@ -1,62 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -/** - * Observes an element's own width. - * - * The viewer is embedded in host applications, where the panel it lives in can be narrow inside a - * perfectly wide window - so `useMediaQuery` and `theme.breakpoints` are the wrong instrument for - * "is there room for this". They answer a question about the *window*, which is the same category of - * mistake as the viewport-width `isMobile` that U-13 removed. A ResizeObserver on the element is a - * real container query: it reports the space the component actually has. - * - * Returns null until the first measurement, so a caller can tell "not measured yet" from "zero - * wide" and avoid deciding a layout on a number it does not have. - * - * `ref` is deliberately a **callback ref**, not a `useRef` object. Consumers here render nothing at - * all much of the time - the mode pill returns null until a mode is armed - and with an object ref - * plus a mount-time effect the observer attaches on the render where the node does not exist yet and - * never re-attaches when it appears. A callback ref fires on every attach and detach. - */ -export function useObservedWidth() { - const [width, setWidth] = useState(null); - const observerRef = useRef(null); - const ref = useCallback((node) => { - var _a; - /** - * Zero is recorded as "not measured", not as a width. An environment that does no layout at - * all - jsdom, where every rect is zeroes - would otherwise look identical to the narrowest - * possible container, and every consumer would silently render its most degraded form under - * test. A truly zero-width container displays nothing either way, so nothing is lost by not - * telling them apart. - * - * Declared inside the callback rather than in the component body: with an empty dependency - * list the callback captures whichever copy existed on the first render, so a helper defined - * outside it is a stale closure waiting for the day it reads a prop. - */ - const record = (value) => setWidth(value > 0 ? value : null); - (_a = observerRef.current) === null || _a === void 0 ? void 0 : _a.disconnect(); - observerRef.current = null; - if (!node) - return; - // Measure immediately as well as on change, so the first paint already has a real number - // rather than one frame of the wrong layout. - record(node.getBoundingClientRect().width); - // Older embedded webviews have no ResizeObserver; the measurement above is better than none, - // and better than throwing. - if (typeof ResizeObserver !== "function") - return; - const observer = new ResizeObserver((entries) => { - const entry = entries[entries.length - 1]; - // contentRect rather than borderBox: every consumer is deciding what fits *inside*. - record(entry.contentRect.width); - }); - observer.observe(node); - observerRef.current = observer; - }, []); - useEffect(() => () => { - var _a; - (_a = observerRef.current) === null || _a === void 0 ? void 0 : _a.disconnect(); - observerRef.current = null; - }, []); - return { ref, width }; -} -export default useObservedWidth; diff --git a/dist/utils/viewSettingsUrl.d.ts b/dist/utils/viewSettingsUrl.d.ts deleted file mode 100644 index dd898494..00000000 --- a/dist/utils/viewSettingsUrl.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * View settings that can be passed via URL query parameters. - * Includes both numeric "viewerSettings" values and boolean toggle settings. - */ -export interface ViewSettingsFromUrl { - atomRadiiScale?: number; - repetitionsAlongLatticeVectorA?: number; - repetitionsAlongLatticeVectorB?: number; - repetitionsAlongLatticeVectorC?: number; - chemicalConnectivityFactor?: number; - isViewAdjustable?: boolean; - orthographicCamera?: boolean; - bonds?: boolean; - axes?: boolean; - autoRotate?: boolean; - elementLabels?: boolean; - coordinateLabels?: boolean; - conventionalCell?: boolean; -} -/** - * Parse URL query parameters into a ViewSettingsFromUrl object. - * Unknown or invalid parameters are silently ignored. - * - * The `repetitions` param supports two formats: - * - Single number (e.g. `repetitions=2`) → applied to all three axes - * - Comma-separated (e.g. `repetitions=2,3,1`) → A=2, B=3, C=1 - * - * Accepts values as strings (from URLSearchParams) or pre-parsed types - * (from Iron Router's getQueryWithParsedBooleansFromRoute which converts - * "true"/"false" strings to actual booleans). - * - * @param params - key-value pairs from URL query string - */ -export declare function parseViewSettingsFromUrlParams(params: Record): ViewSettingsFromUrl; -/** - * Serialize a ViewSettingsFromUrl object back to URL query parameter key-value pairs. - * Only includes values that differ from defaults. Useful for future two-way sync. - */ -export declare function serializeViewSettingsToUrlParams(viewSettings: ViewSettingsFromUrl): Record; diff --git a/dist/utils/viewSettingsUrl.js b/dist/utils/viewSettingsUrl.js deleted file mode 100644 index 692564c0..00000000 --- a/dist/utils/viewSettingsUrl.js +++ /dev/null @@ -1,149 +0,0 @@ -import settings from "../settings"; -/** Registry of recognized URL param names, their types, and how they map to ViewSettingsFromUrl keys. */ -const PARAM_PARSERS = { - atomRadiiScale: { key: "atomRadiiScale", type: "number" }, - repetitions: { key: "repetitionsAlongLatticeVectorA", type: "number" }, // handled specially - chemicalConnectivityFactor: { key: "chemicalConnectivityFactor", type: "number" }, - connectivityFactor: { key: "chemicalConnectivityFactor", type: "number" }, // alias - isViewAdjustable: { key: "isViewAdjustable", type: "boolean" }, - orthographicCamera: { key: "orthographicCamera", type: "boolean" }, - bonds: { key: "bonds", type: "boolean" }, - axes: { key: "axes", type: "boolean" }, - autoRotate: { key: "autoRotate", type: "boolean" }, - elementLabels: { key: "elementLabels", type: "boolean" }, - coordinateLabels: { key: "coordinateLabels", type: "boolean" }, - conventionalCell: { key: "conventionalCell", type: "boolean" }, -}; -function parseNumber(value) { - const n = Number(value); - return Number.isFinite(n) ? n : undefined; -} -function parseBoolean(value) { - if (value === "true" || value === "1") - return true; - if (value === "false" || value === "0") - return false; - return undefined; -} -/** - * Parse URL query parameters into a ViewSettingsFromUrl object. - * Unknown or invalid parameters are silently ignored. - * - * The `repetitions` param supports two formats: - * - Single number (e.g. `repetitions=2`) → applied to all three axes - * - Comma-separated (e.g. `repetitions=2,3,1`) → A=2, B=3, C=1 - * - * Accepts values as strings (from URLSearchParams) or pre-parsed types - * (from Iron Router's getQueryWithParsedBooleansFromRoute which converts - * "true"/"false" strings to actual booleans). - * - * @param params - key-value pairs from URL query string - */ -export function parseViewSettingsFromUrlParams( -// eslint-disable-next-line @typescript-eslint/no-explicit-any -params) { - const result = {}; - Object.entries(params).forEach(([paramName, rawValue]) => { - if (rawValue === undefined || rawValue === "") - return; - const valueStr = String(rawValue); - // Special handling for `repetitions` (comma-separated or single number) - if (paramName === "repetitions") { - const parts = valueStr.split(",").map((s) => s.trim()); - if (parts.length === 1) { - const n = parseNumber(parts[0]); - if (n !== undefined && n >= 1) { - result.repetitionsAlongLatticeVectorA = n; - result.repetitionsAlongLatticeVectorB = n; - result.repetitionsAlongLatticeVectorC = n; - } - } - else if (parts.length === 3) { - const [a, b, c] = parts.map(parseNumber); - if (a !== undefined && a >= 1) - result.repetitionsAlongLatticeVectorA = a; - if (b !== undefined && b >= 1) - result.repetitionsAlongLatticeVectorB = b; - if (c !== undefined && c >= 1) - result.repetitionsAlongLatticeVectorC = c; - } - return; - } - const parser = PARAM_PARSERS[paramName]; - if (!parser) - return; - if (parser.type === "number") { - const n = parseNumber(valueStr); - if (n !== undefined) { - result[parser.key] = n; - } - } - else if (parser.type === "boolean") { - // Accept pre-parsed booleans (from Iron Router) or string values - if (typeof rawValue === "boolean") { - result[parser.key] = rawValue; - } - else { - const b = parseBoolean(valueStr); - if (b !== undefined) { - result[parser.key] = b; - } - } - } - }); - return result; -} -/** - * Serialize a ViewSettingsFromUrl object back to URL query parameter key-value pairs. - * Only includes values that differ from defaults. Useful for future two-way sync. - */ -export function serializeViewSettingsToUrlParams(viewSettings) { - const params = {}; - if (viewSettings.atomRadiiScale !== undefined && - viewSettings.atomRadiiScale !== settings.atomRadiiScale) { - params.atomRadiiScale = String(viewSettings.atomRadiiScale); - } - if (viewSettings.chemicalConnectivityFactor !== undefined && - viewSettings.chemicalConnectivityFactor !== settings.chemicalConnectivityFactor) { - params.chemicalConnectivityFactor = String(viewSettings.chemicalConnectivityFactor); - } - // Serialize repetitions as comma-separated if any differ from default - const repA = viewSettings.repetitionsAlongLatticeVectorA; - const repB = viewSettings.repetitionsAlongLatticeVectorB; - const repC = viewSettings.repetitionsAlongLatticeVectorC; - if (repA !== undefined || repB !== undefined || repC !== undefined) { - const a = repA !== null && repA !== void 0 ? repA : settings.repetitions; - const b = repB !== null && repB !== void 0 ? repB : settings.repetitions; - const c = repC !== null && repC !== void 0 ? repC : settings.repetitions; - if (a !== settings.repetitions || - b !== settings.repetitions || - c !== settings.repetitions) { - if (a === b && b === c) { - params.repetitions = String(a); - } - else { - params.repetitions = `${a},${b},${c}`; - } - } - } - // Boolean toggle settings — only include when true (since defaults are false) - const booleanParams = [ - { key: "orthographicCamera", urlKey: "orthographicCamera" }, - { key: "bonds", urlKey: "bonds" }, - { key: "axes", urlKey: "axes" }, - { key: "autoRotate", urlKey: "autoRotate" }, - { key: "elementLabels", urlKey: "elementLabels" }, - { key: "coordinateLabels", urlKey: "coordinateLabels" }, - { key: "conventionalCell", urlKey: "conventionalCell" }, - ]; - booleanParams.forEach(({ key, urlKey }) => { - if (viewSettings[key] !== undefined) { - params[urlKey] = String(viewSettings[key]); - } - }); - if (viewSettings.isViewAdjustable !== undefined && - viewSettings.isViewAdjustable !== settings.isViewAdjustable) { - params.isViewAdjustable = String(viewSettings.isViewAdjustable); - } - return params; -} diff --git a/dist/wave.d.ts b/dist/wave.d.ts deleted file mode 100644 index 0428884c..00000000 --- a/dist/wave.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Wave draws atoms as spheres according to the material geometry passed. - */ -export class Wave { - /** - * - * @param {Object} config - */ - constructor(config: Object); - rebuildScene(): void; - render(): void; - doFunc(func: any): void; - clearView(): void; - adjustCamerasAndOrbitControlsToCell(): void; - collectSelectableAtoms(): any[]; -} diff --git a/dist/wave.js b/dist/wave.js deleted file mode 100644 index 1ae2b4b4..00000000 --- a/dist/wave.js +++ /dev/null @@ -1,318 +0,0 @@ -/* eslint-disable max-classes-per-file */ -import "./stylesheets/main.css"; -import { mix } from "mixwith"; -import * as THREE from "three"; -import { ATOM_GROUP_NAME } from "./enums"; -import { AtomsMixin } from "./mixins/atoms"; -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"; -const TV3 = THREE.Vector3; -const TCo = THREE.Color; -/* - * WaveBase is a helper class to initialize three js variables, settings and dimensions. - * Initializes a renderer, camera and scene. - */ -class WaveBase { - /** - * Create a WaveBase class. - * @params DOMElement {Object} The container DOM element to attach three.js to. - * @params structure {Object|String} Material structure. - * @params cell {Object} Lattice vectors forming the unit cell (to draw the unit cell). - * @params settings {Object} Setting object to override the default values. - */ - constructor({ DOMElement, structure, cell, settings = {} }) { - this._structure = structure; - this._cell = cell; - // Visible Scene Height = (Max between cell height and width) x PADDING_RATIO, - // e.g. when PADDING_RATIO = 1.25 and the cell height is more than its width, - // the top and bottom padding within viewport equals half of 25% of cell height. - this.PADDING_RATIO = 1.25; - this.container = DOMElement; - this.updateSettings(settings); - this.areLabelsShown = this.settings.areLabelsInitiallyShown; - this.initDimensions(); - this.initRenderer(); - this.initScene(); - this.initCameras(); - this.initStructureGroup(); - this.setupLights(); - this.handleResize = this.handleResize.bind(this); - this.setBackground = this.setBackground.bind(this); - this.doFunc = this.doFunc.bind(this); - } - updateSettings(settings) { - this.settings = { ...SETTINGS, ...settings }; - } - initDimensions() { - this.WIDTH = this.container.clientWidth; - this.HEIGHT = this.container.clientHeight; - this.ASPECT = this.WIDTH > 0 && this.HEIGHT > 0 ? this.WIDTH / this.HEIGHT : 1; - } - // eslint-disable-next-line class-methods-use-this - getWebGLRenderer(config) { - return new THREE.WebGLRenderer(config); - } - initRenderer() { - this.renderer = this.getWebGLRenderer({ - antialias: true, - alpha: true, - preserveDrawingBuffer: true, - }); - this.renderer.sortObjects = false; - this.renderer.domElement.style.width = "100%"; - this.renderer.domElement.style.height = "100%"; - // Without this the browser claims touch drags for scrolling and pinch-zooming the page, and - // sends `pointercancel` to everything that had started tracking the gesture - so on a phone - // or a touch laptop no drag ever reached the viewer: not orbiting, not dragging an atom, not - // marquee-selecting (U-13). Every pointer handler in the editor is already pointer-event - // based, so this one declaration is what makes them work with a finger. - this.renderer.domElement.style.touchAction = "none"; - this.container.appendChild(this.renderer.domElement); - this.renderer.setSize(this.WIDTH, this.HEIGHT); - // Observes the container itself (not the window) so resizing works correctly when the - // container's size changes for reasons other than a window resize (e.g. a layout panel - // opening/closing) - disconnected in dispose() to avoid leaking across reset/re-init. - this._resizeObserver = new ResizeObserver(() => this.handleResize()); - this._resizeObserver.observe(this.container); - } - /** - * Releases the renderer/WebGL context and the resize observer. Must be called before - * discarding a Wave instance (e.g. on component unmount or before constructing a - * replacement instance for the same container), otherwise both leak. - */ - dispose() { - if (this._resizeObserver) { - this._resizeObserver.disconnect(); - this._resizeObserver = null; - } - if (this.renderer) { - this.renderer.dispose(); - } - } - /** - * Adds a camera with given type and args to the scene. - * @param type {String} camera type. - * @param args {Array} arguments passed to the camera constructor. - */ - addCameraToScene(type, ...args) { - const camera = new THREE[type](...args); - camera.name = type; - camera.position.copy(new TV3(...this.settings.initialCameraPosition)); - camera.up = new TV3(0, 0, 1); - this.scene.add(camera); - return camera; - } - /** - * Initializes the cameras; frustum size and distance to camera - * are set only for initialization; - * on the next step, they are adjusted to cell geometry - */ - initCameras() { - const perspectiveCameraParams = [20, this.ASPECT, 1, 20000]; - this.perspectiveCamera = this.addCameraToScene("PerspectiveCamera", ...perspectiveCameraParams); - const orthographicCameraParams = [-10 * this.ASPECT, 10 * this.ASPECT, 10, -10, 1, 1000]; - this.orthographicCamera = this.addCameraToScene("OrthographicCamera", ...orthographicCameraParams); - this.camera = this.perspectiveCamera; // set default camera - } - /** - * Places both cameras in the point displaced from the cell center along the negative X-axis - * and adjusts perspective camera position and orthographic camera frustum so that - * the viewport contains the entire cell with the padding set by PADDING_RATIO; - * takes the cell's center point in the form of a coordinate array and - * the maximum between the height and width. - * @param {{center:Array, maxSize:Number}} - */ - adjustCamerasTargetAndFrustum({ center, maxSize }) { - const fovInRadians = (this.perspectiveCamera.fov * Math.PI) / 180; - const distanceToCamera = (this.PADDING_RATIO * maxSize) / Math.tan(fovInRadians); - this.perspectiveCamera.position.copy(new TV3(-distanceToCamera, center[1], center[2] + 10)); - this.perspectiveCamera.lookAt(new TV3(...center)); - this.orthographicCamera.position.copy(new TV3(-500, center[1], center[2])); - this.setOrthographicCameraFrustum(this.PADDING_RATIO * maxSize); - this.orthographicCamera.lookAt(new TV3(...center)); - } - get isCameraOrthographic() { - return this.camera.isOrthographicCamera; - } - toggleOrthographicCamera() { - this.camera = this.isCameraOrthographic ? this.perspectiveCamera : this.orthographicCamera; - this.camera.add(this.directionalLight); - this.camera.add(this.ambientLight); - this.orbitControls.object = this.camera; - } - /** - * Helper method to set the orthographic frustum dimensions based on - * the required scene size and the aspect ratio of the viewport - * @param {number} sceneSize - */ - setOrthographicCameraFrustum(sceneSize) { - this.orthographicCamera.left = (-sceneSize / 2) * this.ASPECT; - this.orthographicCamera.right = (sceneSize / 2) * this.ASPECT; - this.orthographicCamera.top = sceneSize / 2; - this.orthographicCamera.bottom = -sceneSize / 2; - // Kept here rather than left to each caller so the frustum fields and the matrix that - // actually projects can never disagree. adjustCamerasTargetAndFrustum did not update it, - // so between construction and the first resize the orthographic camera rendered the - // initial +-10 frustum from initCameras instead of the cell-fitted one - invisible in a - // browser, where ResizeObserver fires immediately and handleResize repaired it, and - // load-bearing for figure export, whose scale bar reads these fields. - this.orthographicCamera.updateProjectionMatrix(); - } - initScene() { - this.scene = new THREE.Scene(); - this.scene.name = "Scene"; - this.scene.background = new TCo(this.settings.backgroundColor); - this.scene.fog = new THREE.FogExp2(this.settings.backgroundColor, 0.00025 / 100); - } - // eslint-disable-next-line class-methods-use-this - createStructureGroup(structure) { - const structureGroup = new THREE.Group(); - structureGroup.name = structure.name || structure.formula; - return structureGroup; - } - initStructureGroup() { - this.structureGroup = this.createStructureGroup(this._structure); - this.scene.add(this.structureGroup); - } - /** - * Helper method to trigger the reconstruction of the visual on parent node resize - * to avoid image deformation when the user resizes the browser window - * @param {node} domElement - */ - handleResize(domElement = this.container) { - this.setViewportSize(domElement.clientWidth, domElement.clientHeight); - } - /** - * Points the renderer and both cameras at an explicit pixel size. - * - * `updateStyle: false` changes only the drawing buffer and leaves the canvas's CSS size alone, - * which is what figure export needs (mixins/image.js): it renders at a publication resolution - * that the on-screen layout must not follow, and `renderer.setSize` would otherwise replace the - * `width: 100%` set in initRenderer with a pixel width and break the responsive canvas. - * - * @param width {Number} drawing buffer width in pixels - * @param height {Number} drawing buffer height in pixels - * @param updateStyle {Boolean} whether to also set the canvas element's CSS size - */ - setViewportSize(width, height, updateStyle = true) { - const { maxSize } = this.getCellViewParams(); - this.WIDTH = width; - this.HEIGHT = height; - // Guarded as in initDimensions: a container measured at zero height (a collapsed panel, or - // a detached node) otherwise puts NaN into the projection matrix and blanks the canvas. - this.ASPECT = width > 0 && height > 0 ? width / height : 1; - this.renderer.setSize(width, height, updateStyle); - this.perspectiveCamera.aspect = this.ASPECT; - this.perspectiveCamera.updateProjectionMatrix(); - this.setOrthographicCameraFrustum(this.PADDING_RATIO * maxSize); - this.orthographicCamera.updateProjectionMatrix(); - this.render(); - } - setupLights() { - this.directionalLight = new THREE.DirectionalLight("#FFFFFF"); - this.directionalLight.name = "DirectionalLight"; - this.ambientLight = new THREE.AmbientLight("#202020"); - this.ambientLight.name = "AmbientLight"; - this.directionalLight.position.copy(new THREE.Vector3(0.2, 0.2, -1).normalize()); - this.directionalLight.intensity = 1.2; - // Dynamic lights - moving with camera while orbiting/rotating/zooming - this.camera.add(this.directionalLight); - this.camera.add(this.ambientLight); - } - setBackground(hex, a) { - // eslint-disable-next-line no-bitwise, no-param-reassign - a |= 1.0; - this.settings.backgroundColor = hex; - this.renderer.setClearColor(hex, a); - this.scene.fog.color = new TCo(hex); - } -} -/** - * 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, MarqueeSelectionMixin, GroupTransformMixin, InteractiveStructureEditorMixin) { - /** - * - * @param {Object} config - */ - constructor(config) { - super(config); - this.adjustCamerasAndOrbitControlsToCell(); - this.rebuildScene(); - this.rebuildScene = this.rebuildScene.bind(this); - this.render = this.render.bind(this); - this.doFunc = this.doFunc.bind(this); - } - clearView() { - while (this.structureGroup.children.length) { - this.structureGroup.remove(this.structureGroup.children[0]); - } - } - adjustCamerasAndOrbitControlsToCell() { - const cellViewParams = this.getCellViewParams(); - this.adjustCamerasTargetAndFrustum(cellViewParams); - this.adjustOrbitControlsTarget(cellViewParams.center); - } - // Scoped to the FIRST child named ATOM_GROUP_NAME, matching extractBasisFromScene's own - // (already-fixed) traversal in utils.js: RepetitionMixin.repeatAtomsAtRepetitionCoordinates - // adds the real, base-structure group first and every repetition clone after, each also - // named ATOM_GROUP_NAME but with its atoms' userData.atomicIndex deliberately offset out of - // the material's actual range. Iterating every matching group (the previous behavior) made - // those clones - with out-of-range indices - real, clickable, draggable meshes in edit mode: - // selecting one showed a blank/zero coordinate panel (indexing basis.coordinates[] with an - // index that doesn't exist), and dragging one committed a spurious no-op history entry before - // visibly snapping back on the next rebuild (the clone's position is always re-derived from - // the unchanged base atom, never actually stored). - collectSelectableAtoms() { - const atoms = []; - const atomsGroup = this.structureGroup.children.find((group) => group.name === ATOM_GROUP_NAME); - if (atomsGroup) { - atomsGroup.children.forEach((atom) => { - if (atom instanceof THREE.Mesh) { - atoms.push(atom); - } - }); - } - return atoms; - } - // Called on each change to the Redux store via reloadViewer. - rebuildScene() { - // Rebuilding replaces every atom mesh, so the edit-mode selection (and the gizmo - // attached to it) must be re-pointed at the atoms' new mesh instances afterwards. Uses - // the full multi-select array (D-4), not just the single last-selected atom - otherwise - // a rebuild mid-group-selection (e.g. the host's onStructureModified round-trip calling - // setStructure+rebuildScene again after a group move/rotate/clone) would silently - // collapse the selection down to one atom. - const selectedAtomicIndices = (this.selectedMeshes_ || []).map((mesh) => mesh.userData.atomicIndex); - this.clearView(); - this.drawAtomsAsSpheres(); - this.drawUnitCell(); - this.drawBoundaries(); - if (this.isDrawBondsEnabled) - this.drawBonds(); - this.createAllLabels(); - this.createAllMeasurements(); - if (this.isEditModeEnabled_) - this.reselectAtomsByIndices(selectedAtomicIndices); - this.render(); - } - render() { - this.adjustAllLabelsToCameraPosition(); - this.renderer.render(this.scene, this.camera); - if (this.renderer2) - this.renderer2.render(this.scene2, this.camera2); - } - doFunc(func) { - func(this); - } // for scripting -} diff --git a/package-lock.json b/package-lock.json index 03862860..d12ff354 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,12 +27,12 @@ }, "devDependencies": { "@exabyte-io/eslint-config": "^2025.1.15-0", - "@mat3ra/code": "2026.8.13-0", - "@mat3ra/cove": "2026.7.18-4", - "@mat3ra/esse": "2026.8.13-0", - "@mat3ra/made": "2026.8.13-0", + "@mat3ra/code": "https://github.com/mat3ra/code/releases/download/wip-c893e27/code.tgz", + "@mat3ra/cove": "https://github.com/mat3ra/cove/releases/download/wip-3374a52/cove.tgz", + "@mat3ra/esse": "https://github.com/mat3ra/esse/releases/download/wip-b8494e3c7/esse.tgz", + "@mat3ra/made": "https://github.com/mat3ra/made/releases/download/wip-b5ab8c9/made.tgz", "@mat3ra/tsconfig": "^2024.6.3-0", - "@mat3ra/utils": "^2026.7.8-0", + "@mat3ra/utils": "https://github.com/mat3ra/utils/releases/download/wip-8b2c388/utils.tgz", "@types/react": "^18.2.8", "@types/react-dom": "^18.2.4", "@types/static-kdtree": "^1.0.3", @@ -4068,9 +4068,9 @@ "license": "MIT" }, "node_modules/@mat3ra/code": { - "version": "2026.8.13-0", - "resolved": "https://registry.npmjs.org/@mat3ra/code/-/code-2026.8.13-0.tgz", - "integrity": "sha512-O1FY3b7RvA/ke2AXu64FrKquVxGDFYbQswTiFSdS2LXDuY6Y70sZf3j6GboIQkEMtDiy5v52kDNHeLOKwo7fAw==", + "version": "0.0.0", + "resolved": "https://github.com/mat3ra/code/releases/download/wip-c893e27/code.tgz", + "integrity": "sha512-N9VT/Kf1tU0UHZVkxiBc90++CG3vnS4JQIghkyAqYUv9V7c/OSCScPSPGqujkz+X767QuEQHQBFlU6ANe3aMVQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4112,9 +4112,9 @@ } }, "node_modules/@mat3ra/cove": { - "version": "2026.7.18-4", - "resolved": "https://registry.npmjs.org/@mat3ra/cove/-/cove-2026.7.18-4.tgz", - "integrity": "sha512-RKMMosc5yo9xhymWUD9AlzllULbf079d19E5Zb5nt9poufrYbNoiUQT1wst1I6uDfbGZrlwxbspFEl5pjVAPNw==", + "version": "0.0.0", + "resolved": "https://github.com/mat3ra/cove/releases/download/wip-3374a52/cove.tgz", + "integrity": "sha512-EBBV8Ksd0wlOHABPsqmSeBUerCNBkLDpqQJiTBc03J/SY9w4TnetulGQJCTZ4xxsd/GnvU6LqsIVSms8FO4oPg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4217,9 +4217,9 @@ } }, "node_modules/@mat3ra/esse": { - "version": "2026.8.13-0", - "resolved": "https://registry.npmjs.org/@mat3ra/esse/-/esse-2026.8.13-0.tgz", - "integrity": "sha512-3kMngHQV0l0V/XOYNcJab1I/Vyr7u2gIbxsUuhRmxR+7sIRpx+8xc8TbqDs1u2YvzUgaRqO6zKw0yKrpcG/PXw==", + "version": "0.0.0", + "resolved": "https://github.com/mat3ra/esse/releases/download/wip-b8494e3c7/esse.tgz", + "integrity": "sha512-wA2FlYqIgsYYvtNQZYSSHWzvpFXjSekFsgw641KtyI0cXuAtEpFEWWap0z9avcEA98MfexvmHfm6idTa4VBEbg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5058,9 +5058,9 @@ } }, "node_modules/@mat3ra/made": { - "version": "2026.8.13-0", - "resolved": "https://registry.npmjs.org/@mat3ra/made/-/made-2026.8.13-0.tgz", - "integrity": "sha512-dPcR7epFtwPanSDqwO64HNqKDqTm/P4rOs/kvEaxPCz+fEslTZtAaPOJBVKojsub++jhBESgC6jXPHiRPrYOJA==", + "version": "0.0.0", + "resolved": "https://github.com/mat3ra/made/releases/download/wip-b5ab8c9/made.tgz", + "integrity": "sha512-UdhFBnX/4CZOIeMoCKh0H5O1JfTCAaxDrM11y4Mah8Cdql3fZErYJ9k8Jsfv7T4czBmlnZW9rLjhpm3jUTIgug==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5212,9 +5212,9 @@ } }, "node_modules/@mat3ra/utils": { - "version": "2026.7.8-0", - "resolved": "https://registry.npmjs.org/@mat3ra/utils/-/utils-2026.7.8-0.tgz", - "integrity": "sha512-l7mtptWXfiZnnG4ZdM4vCrp2XBSompra1xmaiZJBpjhOnyVpQy4D+PYordncB2lUVhGqMlD2rmK5Y29oyKQR9Q==", + "version": "0.0.0", + "resolved": "https://github.com/mat3ra/utils/releases/download/wip-8b2c388/utils.tgz", + "integrity": "sha512-vzShVrwuC16hi2HQiZsf7sGwnbu6+azHHsshd3OUVKnup+5vlZuIiFdUTXyK1jU5hoxiecCo2Wqb22UV8vlrzQ==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 0ca6c1d0..4529968a 100644 --- a/package.json +++ b/package.json @@ -58,12 +58,12 @@ }, "devDependencies": { "@exabyte-io/eslint-config": "^2025.1.15-0", - "@mat3ra/code": "2026.8.13-0", - "@mat3ra/cove": "2026.7.18-4", - "@mat3ra/esse": "2026.8.13-0", - "@mat3ra/made": "2026.8.13-0", + "@mat3ra/code": "https://github.com/mat3ra/code/releases/download/wip-c893e27/code.tgz", + "@mat3ra/cove": "https://github.com/mat3ra/cove/releases/download/wip-3374a52/cove.tgz", + "@mat3ra/esse": "https://github.com/mat3ra/esse/releases/download/wip-b8494e3c7/esse.tgz", + "@mat3ra/made": "https://github.com/mat3ra/made/releases/download/wip-b5ab8c9/made.tgz", "@mat3ra/tsconfig": "^2024.6.3-0", - "@mat3ra/utils": "^2026.7.8-0", + "@mat3ra/utils": "https://github.com/mat3ra/utils/releases/download/wip-8b2c388/utils.tgz", "@types/react": "^18.2.8", "@types/react-dom": "^18.2.4", "@types/static-kdtree": "^1.0.3", From 26aaa51c117d278adc49ee331dd52fc518088e29 Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Tue, 18 Aug 2026 00:29:22 +0300 Subject: [PATCH 2/7] refactor: call release-wip/cleanup-wip-releases as reusable workflows mat3ra/actions is being made public, so callers no longer need to checkout it and invoke the composite action locally; call the workflow_call reusable workflow directly instead. [release] --- .github/workflows/cleanup-wip-releases.yml | 26 +++++-------------- .github/workflows/release-wip.yml | 29 +++++----------------- 2 files changed, 12 insertions(+), 43 deletions(-) diff --git a/.github/workflows/cleanup-wip-releases.yml b/.github/workflows/cleanup-wip-releases.yml index 9a69c39b..4fb8e934 100644 --- a/.github/workflows/cleanup-wip-releases.yml +++ b/.github/workflows/cleanup-wip-releases.yml @@ -14,23 +14,9 @@ on: jobs: cleanup-wip-releases: - runs-on: ubuntu-latest - steps: - - name: Checkout this repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Checkout actions repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - repository: mat3ra/actions - # TODO: drop `ref` once mat3ra/actions' js/cleanup-wip-releases merges to main - ref: chore/SOF-7942-1 - token: ${{ secrets.BOT_GITHUB_TOKEN }} - path: actions - - - name: Clean up stale WIP releases - uses: ./actions/js/cleanup-wip-releases - with: - # Scheduled runs always delete; workflow_dispatch defaults to dry-run. - dry-run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run || 'false' }} - github-token: ${{ secrets.BOT_GITHUB_TOKEN }} + uses: mat3ra/actions/.github/workflows/cleanup-wip-releases.yml@main + with: + # Scheduled runs always delete; workflow_dispatch defaults to dry-run. + dry-run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run || false }} + secrets: + github-token: ${{ secrets.BOT_GITHUB_TOKEN }} diff --git a/.github/workflows/release-wip.yml b/.github/workflows/release-wip.yml index b26b47e3..2d12f97b 100644 --- a/.github/workflows/release-wip.yml +++ b/.github/workflows/release-wip.yml @@ -6,26 +6,9 @@ on: push jobs: release-wip: - if: contains(github.event.head_commit.message, '[release]') - runs-on: ubuntu-latest - steps: - - name: Checkout this repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - lfs: true - - - name: Checkout actions repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - repository: mat3ra/actions - # TODO: drop `ref` once mat3ra/actions' js/release-wip merges to main - ref: chore/SOF-7942-1 - token: ${{ secrets.BOT_GITHUB_TOKEN }} - path: actions - - - name: Publish WIP release tarball - uses: ./actions/js/release-wip - with: - package-name: wave.js - build-script: transpile - github-token: ${{ secrets.BOT_GITHUB_TOKEN }} + uses: mat3ra/actions/.github/workflows/release-wip.yml@main + with: + package-name: wave.js + build-script: transpile + secrets: + github-token: ${{ secrets.BOT_GITHUB_TOKEN }} From e8b4217a94aee64d6fe3e1b36c0b897279e88472 Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Tue, 18 Aug 2026 01:13:04 +0300 Subject: [PATCH 3/7] chore: retrigger release-wip now that mat3ra/actions is public [release] From c1c2cc4970a5c54a2bcaaadec9f9e53c05dfab63 Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Tue, 18 Aug 2026 01:17:47 +0300 Subject: [PATCH 4/7] fix: pin release-wip's node-version to 20.x for the gl native dependency mat3ra/actions' release-wip reusable workflow now defaults to Node 24.x, but gl (WebGL headless rendering) has no prebuilt binary for it and fails to compile from source on the runner (missing X11 dev headers). 20.x has a prebuilt binary available. [release] --- .github/workflows/release-wip.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release-wip.yml b/.github/workflows/release-wip.yml index 2d12f97b..2020b3c5 100644 --- a/.github/workflows/release-wip.yml +++ b/.github/workflows/release-wip.yml @@ -10,5 +10,9 @@ jobs: with: package-name: wave.js build-script: transpile + # gl (native WebGL rendering dep) has no prebuilt binary for Node 24 and fails to + # compile from source on the runner (missing X11 dev headers); pin to 20.x where a + # prebuilt binary is available. + node-version: 20.x secrets: github-token: ${{ secrets.BOT_GITHUB_TOKEN }} From 5383cc22af0b72c671853d213e22c35b1d4cb975 Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Tue, 18 Aug 2026 13:07:09 +0300 Subject: [PATCH 5/7] docs: shrink RELEASING.md to a stub, link to mat3ra/actions README Full explanation (why this exists, tag scheme, EINTEGRITY gotcha, cleanup policy) now lives in one place instead of duplicated near- identically across 22 repos. --- RELEASING.md | 61 ++++++++++------------------------------------------ 1 file changed, 11 insertions(+), 50 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 9facbf33..19f85da9 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,69 +1,30 @@ # Releasing WIP builds for consumers -`dist/` is build output and is gitignored — it is not committed on any branch. Published -releases (`npm publish`, tagged versions) are unaffected: `npm pack`/`npm publish` build -their file list from the `files` field in `package.json`, not from what's tracked in git. +`dist/` is build output and is gitignored — it is not committed on any branch. CI +publishes a pre-release tarball (`wip-`) whenever a pushed commit +message contains `[release]`, via the reusable workflow at +[`mat3ra/actions`](https://github.com/mat3ra/actions#release-wip-usage-reusable-workflow). +See that README for the full explanation: why this exists, the tag/URL scheme, the +`EINTEGRITY`/npm-cache gotcha on a same-commit rebuild, and the cleanup policy. -To let a consumer (e.g. `web-app`) install a not-yet-mergeable WIP commit without -`github:mat3ra/wave.js#branch` (which has no way to build `dist/` on install), CI publishes -a pre-release tarball asset instead. - -Releases are tagged per **commit**, not per branch: `wip-`. Each commit -gets its own immutable tag/asset URL, so a consumer pinned to a specific commit's tarball -never has the content underneath that URL silently change. - -## Publishing (CI only) - -Include `[release]` anywhere in a commit message and push. `.github/workflows/release-wip.yml` -runs the `js/release-wip` action (from [`mat3ra/actions`](https://github.com/mat3ra/actions)) -on that push, which builds, packs, and publishes the pre-release tarball asset for that -commit. Commits without the marker don't trigger it, so routine pushes stay quiet. - -There is no local/manual equivalent — building and publishing only happens in CI, so the -tarball a consumer installs is always reproducible from a pushed commit, not from -whatever happens to be on someone's machine. - -The download URL follows this shape: +Download URL shape: ```text https://github.com/mat3ra/wave.js/releases/download/wip-/wave.js.tgz ``` -Re-triggering CI **on the same commit** (e.g. re-running the workflow) re-uploads over -that commit's existing asset (`gh release upload ... --clobber`) rather than minting a -duplicate. A new commit always gets a new tag/URL. - -## Reinstalling in a consumer after a same-commit rebuild - -Because a same-commit rebuild re-uploads over that commit's existing tag/asset, the -download URL doesn't change — which means a plain `npm install` in the consumer won't -pick up the rebuild: npm's cache stores the tarball keyed by URL, and -`package-lock.json` pins the `integrity` hash from the first install, so a -same-URL-but-changed-content refetch either gets served stale from cache or fails with -`EINTEGRITY`. Force a real refetch instead. In `web-app`, use the wrapper script from -repo root: +Reinstall in `web-app` after a same-commit rebuild (plain `npm install` won't refetch — +see the linked README for why): ```bash npm run mat3ra:install -- wave.js wip- ``` -(`web-app/scripts/mat3ra-install.sh` — installs `@mat3ra/wave.js@` with -`--legacy-peer-deps --force`.) - Delete a pre-release once its commit merges and a real published version supersedes it: ```bash gh release delete wip- --yes ``` -(Or let `cleanup-wip-releases.yml`, below, do it automatically once that commit is no -longer any branch's tip.) - -## Automatic cleanup - -`.github/workflows/cleanup-wip-releases.yml` runs weekly (Monday 06:00 UTC) via the -`js/cleanup-wip-releases` action and deletes any `wip-*` release whose commit is no -longer the tip of any branch. This is branch-aware, not age-based, and never deletes -anything not marked pre-release, regardless of tag. Trigger it manually from the Actions -tab (`workflow_dispatch`, defaults to `dry-run: true`) to test or force an off-schedule -cleanup. +(Or let this repo's `cleanup-wip-releases.yml` do it automatically once that commit is +no longer any branch's tip.) From 29e8ccc1a5a882e4d5f0b6979b0a143cfcbda7a1 Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Tue, 18 Aug 2026 14:49:40 +0300 Subject: [PATCH 6/7] fix: run full build before npm publish via prepublishOnly The js/publish action never ran a build step - it relied entirely on dist/ already being committed to git. Since dist/ was gitignored in this rollout, a real `npm publish` on merge would ship whatever partial/stale dist/ happens to exist in the CI workspace instead of a full build (already caused broken @mat3ra/esse@2026.8.18-0 and @mat3ra/utils@2026.8.18-0 releases). Adding prepublishOnly (an npm lifecycle hook npm publish runs automatically) running the same build script already used and verified by release-wip.yml. Verified locally: rm -rf dist && npm run prepublishOnly produces a complete dist/. --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 4529968a..91cdc03f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test": "npx jest", "lint": "eslint src tests --report-unused-disable-directives", "lint:fix": "eslint --fix --cache src tests --report-unused-disable-directives", - "prepare": "husky install" + "prepare": "husky install", + "prepublishOnly": "npm run transpile" }, "repository": { "type": "git", From 9f83ec8a3aadbb73e250a24312e7139e89b354c4 Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Wed, 19 Aug 2026 14:37:54 +0300 Subject: [PATCH 7/7] chore: point cove, code, esse, made, utils at published versions All five merged and published clean (cove 2026.8.19-4, code 2026.8.18-0, esse 2026.8.18-2, made 2026.8.18-0, utils 2026.8.18-1), superseding the WIP tarball URLs. Full jest suite passes (33/33 suites, 464/466 tests, 2 pre-existing skips). [release] --- package-lock.json | 40 ++++++++++++++++++++-------------------- package.json | 10 +++++----- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index d12ff354..b1a7deeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,12 +27,12 @@ }, "devDependencies": { "@exabyte-io/eslint-config": "^2025.1.15-0", - "@mat3ra/code": "https://github.com/mat3ra/code/releases/download/wip-c893e27/code.tgz", - "@mat3ra/cove": "https://github.com/mat3ra/cove/releases/download/wip-3374a52/cove.tgz", - "@mat3ra/esse": "https://github.com/mat3ra/esse/releases/download/wip-b8494e3c7/esse.tgz", - "@mat3ra/made": "https://github.com/mat3ra/made/releases/download/wip-b5ab8c9/made.tgz", + "@mat3ra/code": "2026.8.18-0", + "@mat3ra/cove": "2026.8.19-4", + "@mat3ra/esse": "2026.8.18-2", + "@mat3ra/made": "2026.8.18-0", "@mat3ra/tsconfig": "^2024.6.3-0", - "@mat3ra/utils": "https://github.com/mat3ra/utils/releases/download/wip-8b2c388/utils.tgz", + "@mat3ra/utils": "2026.8.18-1", "@types/react": "^18.2.8", "@types/react-dom": "^18.2.4", "@types/static-kdtree": "^1.0.3", @@ -4068,9 +4068,9 @@ "license": "MIT" }, "node_modules/@mat3ra/code": { - "version": "0.0.0", - "resolved": "https://github.com/mat3ra/code/releases/download/wip-c893e27/code.tgz", - "integrity": "sha512-N9VT/Kf1tU0UHZVkxiBc90++CG3vnS4JQIghkyAqYUv9V7c/OSCScPSPGqujkz+X767QuEQHQBFlU6ANe3aMVQ==", + "version": "2026.8.18-0", + "resolved": "https://registry.npmjs.org/@mat3ra/code/-/code-2026.8.18-0.tgz", + "integrity": "sha512-/dhzWGHiH824MKoL0ZNNGD8jGyYqnWoIgoCyIJXgwMwav4Bm9hZIzumBnbpAn/mQD7MEC3l84L03uNllehMEuA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4112,9 +4112,9 @@ } }, "node_modules/@mat3ra/cove": { - "version": "0.0.0", - "resolved": "https://github.com/mat3ra/cove/releases/download/wip-3374a52/cove.tgz", - "integrity": "sha512-EBBV8Ksd0wlOHABPsqmSeBUerCNBkLDpqQJiTBc03J/SY9w4TnetulGQJCTZ4xxsd/GnvU6LqsIVSms8FO4oPg==", + "version": "2026.8.19-4", + "resolved": "https://registry.npmjs.org/@mat3ra/cove/-/cove-2026.8.19-4.tgz", + "integrity": "sha512-7Zd0ZaZ1Bmgjg+bLNeLC59C/ss6gEL3OeIO9Cp+F46WJajulJysgYwT3LDAZrZcMp/Aj7Er5LH6Fxj2C7dQSZg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4217,9 +4217,9 @@ } }, "node_modules/@mat3ra/esse": { - "version": "0.0.0", - "resolved": "https://github.com/mat3ra/esse/releases/download/wip-b8494e3c7/esse.tgz", - "integrity": "sha512-wA2FlYqIgsYYvtNQZYSSHWzvpFXjSekFsgw641KtyI0cXuAtEpFEWWap0z9avcEA98MfexvmHfm6idTa4VBEbg==", + "version": "2026.8.18-2", + "resolved": "https://registry.npmjs.org/@mat3ra/esse/-/esse-2026.8.18-2.tgz", + "integrity": "sha512-SMkofrqu/P5EKwTkgpi1Q5Mxfca6ZIvhWEK+YeUJDqu203+Z6plAaKx4N4xWFEt91jzNI5Slml/EFiaoVbiFzw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5058,9 +5058,9 @@ } }, "node_modules/@mat3ra/made": { - "version": "0.0.0", - "resolved": "https://github.com/mat3ra/made/releases/download/wip-b5ab8c9/made.tgz", - "integrity": "sha512-UdhFBnX/4CZOIeMoCKh0H5O1JfTCAaxDrM11y4Mah8Cdql3fZErYJ9k8Jsfv7T4czBmlnZW9rLjhpm3jUTIgug==", + "version": "2026.8.18-0", + "resolved": "https://registry.npmjs.org/@mat3ra/made/-/made-2026.8.18-0.tgz", + "integrity": "sha512-00rb8HU7Kt8B9wARuJbhbfxFvK2KG0+aF8wJImMqV3jusUZ0qGp0sfQf3EYc68Z9VFMnSYMgiKROm9RajlfmfQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5212,9 +5212,9 @@ } }, "node_modules/@mat3ra/utils": { - "version": "0.0.0", - "resolved": "https://github.com/mat3ra/utils/releases/download/wip-8b2c388/utils.tgz", - "integrity": "sha512-vzShVrwuC16hi2HQiZsf7sGwnbu6+azHHsshd3OUVKnup+5vlZuIiFdUTXyK1jU5hoxiecCo2Wqb22UV8vlrzQ==", + "version": "2026.8.18-1", + "resolved": "https://registry.npmjs.org/@mat3ra/utils/-/utils-2026.8.18-1.tgz", + "integrity": "sha512-0J9s42sL/2S9tuwjUbxwo6/ORlGCbCvEqYSmHZgSmHNfzIc2mwuiXRNF0S5Al9EJDfYy6VO4WabDCrfpvCci8A==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 91cdc03f..30002cd1 100644 --- a/package.json +++ b/package.json @@ -59,12 +59,12 @@ }, "devDependencies": { "@exabyte-io/eslint-config": "^2025.1.15-0", - "@mat3ra/code": "https://github.com/mat3ra/code/releases/download/wip-c893e27/code.tgz", - "@mat3ra/cove": "https://github.com/mat3ra/cove/releases/download/wip-3374a52/cove.tgz", - "@mat3ra/esse": "https://github.com/mat3ra/esse/releases/download/wip-b8494e3c7/esse.tgz", - "@mat3ra/made": "https://github.com/mat3ra/made/releases/download/wip-b5ab8c9/made.tgz", + "@mat3ra/code": "2026.8.18-0", + "@mat3ra/cove": "2026.8.19-4", + "@mat3ra/esse": "2026.8.18-2", + "@mat3ra/made": "2026.8.18-0", "@mat3ra/tsconfig": "^2024.6.3-0", - "@mat3ra/utils": "https://github.com/mat3ra/utils/releases/download/wip-8b2c388/utils.tgz", + "@mat3ra/utils": "2026.8.18-1", "@types/react": "^18.2.8", "@types/react-dom": "^18.2.4", "@types/static-kdtree": "^1.0.3",