diff --git a/.github/workflows/cleanup-wip-releases.yml b/.github/workflows/cleanup-wip-releases.yml new file mode 100644 index 00000000..4fb8e934 --- /dev/null +++ b/.github/workflows/cleanup-wip-releases.yml @@ -0,0 +1,22 @@ +--- + +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: + 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 new file mode 100644 index 00000000..2020b3c5 --- /dev/null +++ b/.github/workflows/release-wip.yml @@ -0,0 +1,18 @@ +--- + +name: Publish WIP release tarball + +on: push + +jobs: + release-wip: + uses: mat3ra/actions/.github/workflows/release-wip.yml@main + 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 }} 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..19f85da9 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,30 @@ +# Releasing WIP builds for consumers + +`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. + +Download URL shape: + +```text +https://github.com/mat3ra/wave.js/releases/download/wip-/wave.js.tgz +``` + +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- +``` + +Delete a pre-release once its commit merges and a real published version supersedes it: + +```bash +gh release delete wip- --yes +``` + +(Or let this repo's `cleanup-wip-releases.yml` do it automatically once that commit is +no longer any branch's tip.) 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..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": "2026.8.13-0", - "@mat3ra/cove": "2026.7.18-4", - "@mat3ra/esse": "2026.8.13-0", - "@mat3ra/made": "2026.8.13-0", + "@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": "^2026.7.8-0", + "@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": "2026.8.13-0", - "resolved": "https://registry.npmjs.org/@mat3ra/code/-/code-2026.8.13-0.tgz", - "integrity": "sha512-O1FY3b7RvA/ke2AXu64FrKquVxGDFYbQswTiFSdS2LXDuY6Y70sZf3j6GboIQkEMtDiy5v52kDNHeLOKwo7fAw==", + "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": "2026.7.18-4", - "resolved": "https://registry.npmjs.org/@mat3ra/cove/-/cove-2026.7.18-4.tgz", - "integrity": "sha512-RKMMosc5yo9xhymWUD9AlzllULbf079d19E5Zb5nt9poufrYbNoiUQT1wst1I6uDfbGZrlwxbspFEl5pjVAPNw==", + "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": "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": "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": "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": "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": "2026.7.8-0", - "resolved": "https://registry.npmjs.org/@mat3ra/utils/-/utils-2026.7.8-0.tgz", - "integrity": "sha512-l7mtptWXfiZnnG4ZdM4vCrp2XBSompra1xmaiZJBpjhOnyVpQy4D+PYordncB2lUVhGqMlD2rmK5Y29oyKQR9Q==", + "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 0ca6c1d0..30002cd1 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", @@ -58,12 +59,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": "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": "^2026.7.8-0", + "@mat3ra/utils": "2026.8.18-1", "@types/react": "^18.2.8", "@types/react-dom": "^18.2.4", "@types/static-kdtree": "^1.0.3",