From c8ddcc68d5201ce247abc18412a83d451132042a Mon Sep 17 00:00:00 2001 From: Yulei Chen Date: Mon, 26 Jun 2023 16:22:08 +0800 Subject: [PATCH] #2760 - feat: snap partially selected structure to an attachment bond --- .../src/application/render/draw.ts | 103 +++++-- .../src/application/render/options.ts | 5 + .../src/application/render/render.types.ts | 1 + .../src/application/render/restruct/rebond.ts | 170 +++++++++-- .../application/render/restruct/restruct.ts | 13 + .../src/script/editor/shared/utils.js | 24 +- .../script/editor/tool/rotate-controller.ts | 102 ++++++- .../src/script/editor/tool/rotate.ts | 265 +++++++++++++++++- 8 files changed, 615 insertions(+), 68 deletions(-) diff --git a/packages/ketcher-core/src/application/render/draw.ts b/packages/ketcher-core/src/application/render/draw.ts index 36e44a6a63..8970fbb641 100644 --- a/packages/ketcher-core/src/application/render/draw.ts +++ b/packages/ketcher-core/src/application/render/draw.ts @@ -976,14 +976,19 @@ function bondSingle( halfBond1: HalfBond, halfBond2: HalfBond, options: RenderOptions, + isSnapping: boolean, color = '#000' ) { const a = halfBond1.p const b = halfBond2.p - return paper.path(makeStroke(a, b)).attr(options.lineattr).attr({ - fill: color, - stroke: color - }) + return paper + .path(makeStroke(a, b)) + .attr(options.lineattr) + .attr({ + fill: color, + stroke: color + }) + .attr(isSnapping ? options.bondSnappingStyle : {}) } function bondSingleUp( @@ -992,6 +997,7 @@ function bondSingleUp( b2: Vec2, b3: Vec2, options: RenderOptions, + isSnapping: boolean, color = '#000' ) { // eslint-disable-line max-params @@ -1010,6 +1016,7 @@ function bondSingleUp( fill: color, stroke: color }) + .attr(isSnapping ? options.bondSnappingStyle : {}) } function bondSingleStereoBold( @@ -1019,6 +1026,7 @@ function bondSingleStereoBold( a3: Vec2, a4: Vec2, options: RenderOptions, + isSnapping: boolean, color = '#000' ) { // eslint-disable-line max-params @@ -1035,10 +1043,11 @@ function bondSingleStereoBold( tfx(a4.y) ) .attr(options.lineattr) - bond.attr({ - stroke: color, - fill: color - }) + .attr({ + stroke: color, + fill: color + }) + .attr(isSnapping ? options.bondSnappingStyle : {}) return bond } @@ -1048,6 +1057,7 @@ function bondDoubleStereoBold( b1: Vec2, b2: Vec2, options: RenderOptions, + isSnapping: boolean, color = '#000' ) { // eslint-disable-line max-params @@ -1060,6 +1070,7 @@ function bondDoubleStereoBold( stroke: color, fill: color }) + .attr(isSnapping ? options.bondSnappingStyle : {}) ]) } @@ -1070,6 +1081,7 @@ function bondSingleDown( nlines: number, step: number, options: RenderOptions, + isSnapping: boolean, color = '#000' ) { // eslint-disable-line max-params @@ -1087,10 +1099,14 @@ function bondSingleDown( q = r.addScaled(n, (-bsp * (i + 0.5)) / (nlines - 0.5)) path += makeStroke(p, q) } - return paper.path(path).attr(options.lineattr).attr({ - fill: color, - stroke: color - }) + return paper + .path(path) + .attr(options.lineattr) + .attr({ + fill: color, + stroke: color + }) + .attr(isSnapping ? options.bondSnappingStyle : {}) } function bondSingleEither( @@ -1100,6 +1116,7 @@ function bondSingleEither( nlines: number, step: number, options: RenderOptions, + isSnapping: boolean, color = '#000' ) { // eslint-disable-line max-params @@ -1115,10 +1132,14 @@ function bondSingleEither( .addScaled(n, ((i & 1 ? -1 : +1) * bsp * (i + 0.5)) / (nlines - 0.5)) path += 'L' + tfx(r.x) + ',' + tfx(r.y) } - return paper.path(path).attr(options.lineattr).attr({ - fill: color, - stroke: color - }) + return paper + .path(path) + .attr(options.lineattr) + .attr({ + fill: color, + stroke: color + }) + .attr(isSnapping ? options.bondSnappingStyle : {}) } function bondDouble( @@ -1128,7 +1149,8 @@ function bondDouble( b1: Vec2, b2: Vec2, cisTrans: boolean, - options: RenderOptions + options: RenderOptions, + isSnapping: boolean ) { // eslint-disable-line max-params return paper @@ -1146,6 +1168,7 @@ function bondDouble( tfx(b2.y) ) .attr(options.lineattr) + .attr(isSnapping ? options.bondSnappingStyle : {}) } function bondSingleOrDouble( @@ -1153,7 +1176,8 @@ function bondSingleOrDouble( halfBond1: HalfBond, halfBond2: HalfBond, nSect: number, - options: RenderOptions + options: RenderOptions, + isSnapping: boolean ) { // eslint-disable-line max-statements, max-params const a = halfBond1.p @@ -1174,7 +1198,10 @@ function bondSingleOrDouble( } pp = pi } - return paper.path(path).attr(options.lineattr) + return paper + .path(path) + .attr(options.lineattr) + .attr(isSnapping ? options.bondSnappingStyle : {}) } function bondTriple( @@ -1182,6 +1209,7 @@ function bondTriple( halfBond1: HalfBond, halfBond2: HalfBond, options: RenderOptions, + isSnapping: boolean, color = '#000' ) { const a = halfBond1.p @@ -1198,16 +1226,24 @@ function bondTriple( fill: color, stroke: color }) + .attr(isSnapping ? options.bondSnappingStyle : {}) } function bondAromatic( paper: RaphaelPaper, paths: string[], bondShift: number, - options: RenderOptions + options: RenderOptions, + isSnapping: boolean ) { - const l1 = paper.path(paths[0]).attr(options.lineattr) - const l2 = paper.path(paths[1]).attr(options.lineattr) + const l1 = paper + .path(paths[0]) + .attr(options.lineattr) + .attr(isSnapping ? options.bondSnappingStyle : {}) + const l2 = paper + .path(paths[1]) + .attr(options.lineattr) + .attr(isSnapping ? options.bondSnappingStyle : {}) if (bondShift !== undefined && bondShift !== null) { ;(bondShift > 0 ? l1 : l2).attr({ 'stroke-dasharray': '- ' }) } @@ -1219,7 +1255,8 @@ function bondAny( paper: RaphaelPaper, halfBond1: HalfBond, halfBond2: HalfBond, - options: RenderOptions + options: RenderOptions, + isSnapping: boolean ) { const a = halfBond1.p const b = halfBond2.p @@ -1227,27 +1264,34 @@ function bondAny( .path(makeStroke(a, b)) .attr(options.lineattr) .attr({ 'stroke-dasharray': '- ' }) + .attr(isSnapping ? options.bondSnappingStyle : {}) } function bondHydrogen( paper: RaphaelPaper, halfBond1: HalfBond, halfBond2: HalfBond, - options: RenderOptions + options: RenderOptions, + isSnapping: boolean ) { const a = halfBond1.p const b = halfBond2.p - return paper.path(makeStroke(a, b)).attr(options.lineattr).attr({ - 'stroke-dasharray': '.', - 'stroke-linecap': 'square' - }) + return paper + .path(makeStroke(a, b)) + .attr(options.lineattr) + .attr({ + 'stroke-dasharray': '.', + 'stroke-linecap': 'square' + }) + .attr(isSnapping ? options.bondSnappingStyle : {}) } function bondDative( paper: RaphaelPaper, halfBond1: HalfBond, halfBond2: HalfBond, - options: RenderOptions + options: RenderOptions, + isSnapping: boolean ) { const a = halfBond1.p const b = halfBond2.p @@ -1255,6 +1299,7 @@ function bondDative( .path(makeStroke(a, b)) .attr(options.lineattr) .attr({ 'arrow-end': 'block-midium-long' }) + .attr(isSnapping ? options.bondSnappingStyle : {}) } function reactingCenter( diff --git a/packages/ketcher-core/src/application/render/options.ts b/packages/ketcher-core/src/application/render/options.ts index 1f8e48d48e..a0daf69ab3 100644 --- a/packages/ketcher-core/src/application/render/options.ts +++ b/packages/ketcher-core/src/application/render/options.ts @@ -82,6 +82,11 @@ function defaultOptions(options: RenderOptions): RenderOptions { fill: '#365CFF', stroke: '#365CFF' }, + bondSnappingStyle: { + fill: '#365CFF', + stroke: '#365CFF', + 'stroke-width': options.bondThickness * 1.5 + }, /* eslint-enable quote-props */ selectionStyle: { fill: '#57FF8F', diff --git a/packages/ketcher-core/src/application/render/render.types.ts b/packages/ketcher-core/src/application/render/render.types.ts index 5ef093b377..545b977b5f 100644 --- a/packages/ketcher-core/src/application/render/render.types.ts +++ b/packages/ketcher-core/src/application/render/render.types.ts @@ -53,6 +53,7 @@ export type RenderOptions = { /* styles */ lineattr: RenderOptionStyles arrowSnappingStyle: RenderOptionStyles + bondSnappingStyle: RenderOptionStyles selectionStyle: RenderOptionStyles hoverStyle: RenderOptionStyles sgroupBracketStyle: RenderOptionStyles diff --git a/packages/ketcher-core/src/application/render/restruct/rebond.ts b/packages/ketcher-core/src/application/render/restruct/rebond.ts index f4bc0d99cb..82911cfe4a 100644 --- a/packages/ketcher-core/src/application/render/restruct/rebond.ts +++ b/packages/ketcher-core/src/application/render/restruct/rebond.ts @@ -298,7 +298,8 @@ class ReBond extends ReObject { ReBond.bondRecalc(this, restruct, options) setDoubleBondShift(this, struct) if (!hb1 || !hb2) return - this.path = getBondPath(restruct, this, hb1, hb2) + const isSnapping = restruct.isSnappingBond(bid) + this.path = getBondPath(restruct, this, hb1, hb2, isSnapping) this.rbb = util.relBox(this.path.getBBox()) restruct.addReObjectPath(LayerMap.data, this.visel, this.path, null, true) const reactingCenter: any = {} @@ -506,7 +507,8 @@ function getBondPath( restruct: ReStruct, bond: ReBond, hb1: HalfBond, - hb2: HalfBond + hb2: HalfBond, + isSnapping: boolean ) { let path = null const render = restruct.render @@ -520,14 +522,43 @@ function getBondPath( case Bond.PATTERN.STEREO.UP: findIncomingUpBonds(hb1.bid, bond, restruct) if (bond.boldStereo && bond.neihbid1 >= 0 && bond.neihbid2 >= 0) { - path = getBondSingleStereoBoldPath(render, hb1, hb2, bond, struct) - } else path = getBondSingleUpPath(render, hb1, hb2, bond, struct) + path = getBondSingleStereoBoldPath( + render, + hb1, + hb2, + bond, + struct, + isSnapping + ) + } else + path = getBondSingleUpPath( + render, + hb1, + hb2, + bond, + struct, + isSnapping + ) break case Bond.PATTERN.STEREO.DOWN: - path = getBondSingleDownPath(render, hb1, hb2, bond, struct) + path = getBondSingleDownPath( + render, + hb1, + hb2, + bond, + struct, + isSnapping + ) break case Bond.PATTERN.STEREO.EITHER: - path = getBondSingleEitherPath(render, hb1, hb2, bond, struct) + path = getBondSingleEitherPath( + render, + hb1, + hb2, + bond, + struct, + isSnapping + ) break default: path = draw.bondSingle( @@ -535,6 +566,7 @@ function getBondPath( hb1, hb2, render.options, + isSnapping, getStereoBondColor(render.options, bond, struct) ) break @@ -555,39 +587,79 @@ function getBondPath( bond, struct, shiftA, - shiftB + shiftB, + isSnapping + ) + } else + path = getBondDoublePath( + render, + hb1, + hb2, + bond, + shiftA, + shiftB, + isSnapping ) - } else path = getBondDoublePath(render, hb1, hb2, bond, shiftA, shiftB) break case Bond.PATTERN.TYPE.TRIPLE: - path = draw.bondTriple(render.paper, hb1, hb2, render.options) + path = draw.bondTriple(render.paper, hb1, hb2, render.options, isSnapping) break case Bond.PATTERN.TYPE.AROMATIC: { const inAromaticLoop = (hb1.loop >= 0 && struct.loops.get(hb1.loop)?.aromatic) || (hb2.loop >= 0 && struct.loops.get(hb2.loop)?.aromatic) path = inAromaticLoop - ? draw.bondSingle(render.paper, hb1, hb2, render.options) - : getBondAromaticPath(render, hb1, hb2, bond, shiftA, shiftB) + ? draw.bondSingle(render.paper, hb1, hb2, render.options, isSnapping) + : getBondAromaticPath( + render, + hb1, + hb2, + bond, + shiftA, + shiftB, + isSnapping + ) break } case Bond.PATTERN.TYPE.SINGLE_OR_DOUBLE: - path = getSingleOrDoublePath(render, hb1, hb2) + path = getSingleOrDoublePath(render, hb1, hb2, isSnapping) break case Bond.PATTERN.TYPE.SINGLE_OR_AROMATIC: - path = getBondAromaticPath(render, hb1, hb2, bond, shiftA, shiftB) + path = getBondAromaticPath( + render, + hb1, + hb2, + bond, + shiftA, + shiftB, + isSnapping + ) break case Bond.PATTERN.TYPE.DOUBLE_OR_AROMATIC: - path = getBondAromaticPath(render, hb1, hb2, bond, shiftA, shiftB) + path = getBondAromaticPath( + render, + hb1, + hb2, + bond, + shiftA, + shiftB, + isSnapping + ) break case Bond.PATTERN.TYPE.ANY: - path = draw.bondAny(render.paper, hb1, hb2, render.options) + path = draw.bondAny(render.paper, hb1, hb2, render.options, isSnapping) break case Bond.PATTERN.TYPE.HYDROGEN: - path = draw.bondHydrogen(render.paper, hb1, hb2, render.options) + path = draw.bondHydrogen( + render.paper, + hb1, + hb2, + render.options, + isSnapping + ) break case Bond.PATTERN.TYPE.DATIVE: - path = draw.bondDative(render.paper, hb1, hb2, render.options) + path = draw.bondDative(render.paper, hb1, hb2, render.options, isSnapping) break default: throw new Error('Bond type ' + bond.b.type + ' not supported') @@ -601,7 +673,8 @@ function getBondSingleUpPath( hb1: HalfBond, hb2: HalfBond, bond: ReBond, - struct: Struct + struct: Struct, + isSnapping: boolean ) { // eslint-disable-line max-params const a = hb1.p @@ -628,6 +701,7 @@ function getBondSingleUpPath( b2, b3, options, + isSnapping, getStereoBondColor(options, bond, struct) ) } @@ -668,7 +742,8 @@ function getBondSingleStereoBoldPath( hb1: HalfBond, hb2: HalfBond, bond: ReBond, - struct: Struct + struct: Struct, + isSnapping: boolean ) { // eslint-disable-line max-params const options = render.options @@ -695,6 +770,7 @@ function getBondSingleStereoBoldPath( a3, a4, options, + isSnapping, getStereoBondColor(options, bond, struct) ) } @@ -706,7 +782,8 @@ function getBondDoubleStereoBoldPath( bond: ReBond, struct: Struct, shiftA: boolean, - shiftB: boolean + shiftB: boolean, + isSnapping: boolean ) { // eslint-disable-line max-params const a = hb1.p @@ -743,13 +820,21 @@ function getBondDoubleStereoBoldPath( ) } } - const sgBondPath = getBondSingleStereoBoldPath(render, hb1, hb2, bond, struct) + const sgBondPath = getBondSingleStereoBoldPath( + render, + hb1, + hb2, + bond, + struct, + isSnapping + ) return draw.bondDoubleStereoBold( render.paper, sgBondPath, b1, b2, render.options, + isSnapping, getStereoBondColor(render.options, bond, struct) ) } @@ -789,7 +874,8 @@ function getBondSingleDownPath( hb1: HalfBond, hb2: HalfBond, bond: ReBond, - struct: Struct + struct: Struct, + isSnapping: boolean ) { const a = hb1.p const b = hb2.p @@ -811,6 +897,7 @@ function getBondSingleDownPath( nlines, step, options, + isSnapping, getStereoBondColor(options, bond, struct) ) } @@ -820,7 +907,8 @@ function getBondSingleEitherPath( hb1: HalfBond, hb2: HalfBond, bond: ReBond, - struct: Struct + struct: Struct, + isSnapping: boolean ) { const a = hb1.p const b = hb2.p @@ -842,6 +930,7 @@ function getBondSingleEitherPath( nlines, step, options, + isSnapping, getStereoBondColor(options, bond, struct) ) } @@ -852,7 +941,8 @@ function getBondDoublePath( hb2: HalfBond, bond: ReBond, shiftA: boolean, - shiftB: boolean + shiftB: boolean, + isSnapping: boolean ) { // eslint-disable-line max-params, max-statements const cisTrans = bond.b.stereo === Bond.PATTERN.STEREO.CIS_TRANS @@ -900,10 +990,24 @@ function getBondDoublePath( } } - return draw.bondDouble(render.paper, a1, a2, b1, b2, cisTrans, options) + return draw.bondDouble( + render.paper, + a1, + a2, + b1, + b2, + cisTrans, + options, + isSnapping + ) } -function getSingleOrDoublePath(render: Render, hb1: HalfBond, hb2: HalfBond) { +function getSingleOrDoublePath( + render: Render, + hb1: HalfBond, + hb2: HalfBond, + isSnapping: boolean +) { const a = hb1.p const b = hb2.p const options = render.options @@ -911,7 +1015,14 @@ function getSingleOrDoublePath(render: Render, hb1: HalfBond, hb2: HalfBond) { let nSect = Vec2.dist(a, b) / Number((options.bondSpace + options.lineWidth).toFixed()) if (!(nSect & 1)) nSect += 1 - return draw.bondSingleOrDouble(render.paper, hb1, hb2, nSect, options) + return draw.bondSingleOrDouble( + render.paper, + hb1, + hb2, + nSect, + options, + isSnapping + ) } function getBondAromaticPath( @@ -920,7 +1031,8 @@ function getBondAromaticPath( hb2: HalfBond, bond: ReBond, shiftA: boolean, - shiftB: boolean + shiftB: boolean, + isSnapping: boolean ) { // eslint-disable-line max-params const dashdotPattern = [0.125, 0.125, 0.005, 0.125] @@ -947,7 +1059,7 @@ function getBondAromaticPath( mask, dash ) - return draw.bondAromatic(render.paper, paths, bondShift, options) + return draw.bondAromatic(render.paper, paths, bondShift, options, isSnapping) } function getAromaticBondPaths( diff --git a/packages/ketcher-core/src/application/render/restruct/restruct.ts b/packages/ketcher-core/src/application/render/restruct/restruct.ts index 22a32c901e..effb385c26 100644 --- a/packages/ketcher-core/src/application/render/restruct/restruct.ts +++ b/packages/ketcher-core/src/application/render/restruct/restruct.ts @@ -84,6 +84,7 @@ class ReStruct { private enhancedFlagsChanged: Map = new Map() private bondsChanged: Map = new Map() private textsChanged: Map = new Map() + private snappingBonds: number[] = [] constructor(molecule, render: Render) { // eslint-disable-line max-statements this.render = render @@ -706,6 +707,18 @@ class ReStruct { }) } } + + addSnappingBonds(bondId: number) { + this.snappingBonds.push(bondId) + } + + clearSnappingBonds() { + this.snappingBonds = [] + } + + isSnappingBond(bondId: number) { + return this.snappingBonds.includes(bondId) + } } function isSelectionEmpty(selection) { diff --git a/packages/ketcher-react/src/script/editor/shared/utils.js b/packages/ketcher-react/src/script/editor/shared/utils.js index 0997542932..1ee2ed80b1 100644 --- a/packages/ketcher-react/src/script/editor/shared/utils.js +++ b/packages/ketcher-react/src/script/editor/shared/utils.js @@ -36,9 +36,31 @@ function degrees(angle) { return degree } +/** + * @param {number} angle angle (in radians) from the X axis + * @returns {number} normalized angle (in radians) from the X axis + * @example + * normalizeAngleRelativeToXAxis(PI / 2) === PI / 2 + * normalizeAngleRelativeToXAxis(PI) === PI + * normalizeAngleRelativeToXAxis(3/2 * PI) === -PI / 2 + * normalizeAngleRelativeToXAxis(2 * PI) === 0 + * normalizeAngleRelativeToXAxis(3 * PI) === PI + */ +function normalizeAngle(angle) { + const angleWithinFullCircle = angle % (2 * Math.PI) + if (angleWithinFullCircle > Math.PI) { + return angleWithinFullCircle - 2 * Math.PI + } + if (angleWithinFullCircle <= -Math.PI) { + return angleWithinFullCircle + 2 * Math.PI + } + return angleWithinFullCircle +} + export default { calcAngle, fracAngle, calcNewAtomPos, - degrees + degrees, + normalizeAngle } diff --git a/packages/ketcher-react/src/script/editor/tool/rotate-controller.ts b/packages/ketcher-react/src/script/editor/tool/rotate-controller.ts index bf862ba13c..32cd0af164 100644 --- a/packages/ketcher-react/src/script/editor/tool/rotate-controller.ts +++ b/packages/ketcher-react/src/script/editor/tool/rotate-controller.ts @@ -1,6 +1,7 @@ import { Action, Scale, Vec2 } from 'ketcher-core' import { throttle } from 'lodash' import Editor from '../Editor' +import utils from '../shared/utils' import { getGroupIdsFromItemArrays } from './helper/getGroupIdsFromItems' import RotateTool from './rotate' import SelectTool from './select' @@ -13,6 +14,7 @@ type RaphaelElement = { type LinkState = 'long' | 'short' | 'moveCenter' | 'moveHandle' type CrossState = 'active' | 'inactive' | 'offset' | 'move' type HandleState = 'hoverIn' | 'hoverOut' | 'active' | 'move' +type SnapAngleIndicatorState = 'noLine' | 'noText' | 'default' const STYLE = { HANDLE_MARGIN: 15, @@ -42,6 +44,7 @@ class RotateController { private link?: RaphaelElement private protractor?: RaphaelElement private rotateArc?: RaphaelElement + private snapAngleIndicator?: RaphaelElement constructor(editor: Editor) { this.editor = editor @@ -107,6 +110,8 @@ class RotateController { delete this.protractor this.rotateArc?.remove() delete this.rotateArc + this.snapAngleIndicator?.remove() + delete this.snapAngleIndicator } /** @@ -706,13 +711,15 @@ class RotateController { rotateArcStart, textPos ) - // NOTE: draw protractor last + // NOTE: draw protractor behind arc this.drawProtractor( this.rotateTool.dragCtx?.angle || 0, newRadius, degree0Line, degree0TextPos ) + + this.updateSnapAngleIndicator() }, 40 // 25fps ) @@ -796,6 +803,99 @@ class RotateController { rotateHandlePosition: handleCenterInViewport }) }, 40) + + private updateSnapAngleIndicator() { + const snapAngleDrawingProps = this.rotateTool.snapAngleDrawingProps + if ( + snapAngleDrawingProps === null || + (snapAngleDrawingProps.snapMode === 'multiple-bonds' && + snapAngleDrawingProps.isSnapping) + ) { + this.snapAngleIndicator?.remove() + } else { + const { isSnapping, absoluteAngle, relativeAngle, snapMode } = + snapAngleDrawingProps + const drawingState: SnapAngleIndicatorState = isSnapping + ? 'noLine' + : snapMode === 'multiple-bonds' + ? 'noText' + : 'default' + this.drawSnapAngleIndicator(drawingState, absoluteAngle, relativeAngle) + } + } + + private drawSnapAngleIndicator( + state: SnapAngleIndicatorState, + absoluteSnapAngle: number, + relativeSnapAngle: number + ) { + this.snapAngleIndicator?.remove() + this.snapAngleIndicator = this.paper.set() as RaphaelElement + const LINE_LENGTH = 30 + const TEXT_FONT_SIZE = 12 + const relativeSnapAngleInDegrees = utils.degrees(relativeSnapAngle) + + switch (state) { + case 'noLine': { + const textAngle = utils.normalizeAngle( + absoluteSnapAngle - relativeSnapAngle / 2 + ) + const textPosition = new Vec2(20, 0).rotate(textAngle) + const text = this.paper + .text( + textPosition.x, + textPosition.y, + `${Math.abs(relativeSnapAngleInDegrees)}°` + ) + .attr({ + 'font-size': TEXT_FONT_SIZE, + fill: STYLE.ACTIVE_COLOR + }) + this.snapAngleIndicator.push(text) + break + } + + case 'noText': { + const lineVector = new Vec2(LINE_LENGTH, 0).rotate(absoluteSnapAngle) + const line = this.paper + .path(`M0,0` + `l${lineVector.x},${lineVector.y}`) + .attr({ + 'stroke-dasharray': '-', + stroke: STYLE.INITIAL_COLOR + }) + this.snapAngleIndicator.push(line) + break + } + + default: { + const lineVector = new Vec2(LINE_LENGTH, 0).rotate(absoluteSnapAngle) + const line = this.paper + .path(`M0,0` + `l${lineVector.x},${lineVector.y}`) + .attr({ + 'stroke-dasharray': '-', + stroke: STYLE.INITIAL_COLOR + }) + const textPosition = new Vec2(LINE_LENGTH + 15, 0).rotate( + absoluteSnapAngle + ) + const text = this.paper + .text( + textPosition.x, + textPosition.y, + `${Math.abs(relativeSnapAngleInDegrees)}°` + ) + .attr({ + 'font-size': TEXT_FONT_SIZE, + fill: STYLE.INITIAL_COLOR + }) + this.snapAngleIndicator.push(line) + this.snapAngleIndicator.push(text) + break + } + } + + this.snapAngleIndicator.translate(this.center.x, this.center.y) + } } export default RotateController diff --git a/packages/ketcher-react/src/script/editor/tool/rotate.ts b/packages/ketcher-react/src/script/editor/tool/rotate.ts index 0c9896067d..cd9cadc35f 100644 --- a/packages/ketcher-react/src/script/editor/tool/rotate.ts +++ b/packages/ketcher-react/src/script/editor/tool/rotate.ts @@ -15,6 +15,7 @@ ***************************************************************************/ import { + Atom, Bond, FlipDirection, Vec2, @@ -26,15 +27,40 @@ import { isAttachmentBond } from 'ketcher-core' import assert from 'assert' +import { intersection, throttle } from 'lodash' import utils from '../shared/utils' -import Editor from '../Editor' +import Editor, { Selection } from '../Editor' import { Tool } from './Tool' -import { intersection } from 'lodash' + +type SnapMode = 'one-bond' | 'multiple-bonds' +type SnapInfo = { + snapMode: SnapMode + rotatableHalfBondAngle: number + absoluteSnapAngles: number[] + snapAngleToHalfBonds: Map + snapAngleDrawingProps: { + isSnapping: boolean + absoluteAngle: number + relativeAngle: number + } | null +} + +const SNAP_ANGLES_RELATIVE_TO_FIXED_BOND = [ + Math.PI / 2, + -Math.PI / 2, + (2 * Math.PI) / 3, + -(2 * Math.PI) / 3, + Math.PI +] // 90, -90, 120, -120, 180 degrees +const MAX_SNAP_DELTA = Math.PI / 18 // 10 degrees +const ANGLE_INDICATOR_VISIBLE_DELTA = Math.PI / 9 // 20 degrees class RotateTool implements Tool { private readonly editor: Editor dragCtx: any isNotActiveTool = true + private centerAtomId?: number + private snapInfo: SnapInfo | null = null constructor(editor: Editor, flipDirection?: FlipDirection) { this.editor = editor @@ -65,11 +91,22 @@ class RotateTool implements Tool { return this.editor.selection() } + public get snapAngleDrawingProps() { + if (this.snapInfo?.snapAngleDrawingProps) { + return { + snapMode: this.snapInfo.snapMode, + ...this.snapInfo.snapAngleDrawingProps + } + } + return null + } + mousedownHandle(handleCenter: Vec2, center: Vec2) { this.dragCtx = { xy0: center, angle1: utils.calcAngle(center, handleCenter) } + this.initSnapInfo() } getCenter() { @@ -100,7 +137,8 @@ class RotateTool implements Tool { intersectionAtoms.length === 1 && visibleAtoms.includes(intersectionAtoms[0]) ) { - center = this.struct.atoms.get(intersectionAtoms[0])?.pp + this.centerAtomId = intersectionAtoms[0] + center = this.struct.atoms.get(this.centerAtomId)?.pp } } else if (attachmentBonds.size === 1) { /** @@ -116,13 +154,13 @@ class RotateTool implements Tool { */ const attachmentBondId = attachmentBonds.keys().next().value as number const attachmentBond = attachmentBonds.get(attachmentBondId) as Bond - const rotatePoint = [attachmentBond.begin, attachmentBond.end].find( + this.centerAtomId = [attachmentBond.begin, attachmentBond.end].find( (atomId) => this.selection?.bonds?.includes(attachmentBondId) ? !visibleAtoms.includes(atomId) : visibleAtoms.includes(atomId) ) as number - center = this.struct.atoms.get(rotatePoint)?.pp + center = this.struct.atoms.get(this.centerAtomId)?.pp } const { texts, rxnArrows, rxnPluses } = this.selection @@ -145,7 +183,7 @@ class RotateTool implements Tool { return center } - mousemove(event) { + mousemove = throttle((event) => { if (!this.dragCtx) { this.editor.hover(null, null, event) return true @@ -158,8 +196,15 @@ class RotateTool implements Tool { utils.calcAngle(dragCtx.xy0, mousePos) - dragCtx.angle1 let rotateAngle = mouseMoveAngle + this.reStruct.clearSnappingBonds() + if (this.snapInfo) { + this.snapInfo.snapAngleDrawingProps = null + } if (!event.ctrlKey) { - rotateAngle = utils.fracAngle(mouseMoveAngle, null) + const [isSnapping, rotateAngleWithSnapping] = this.snap(mouseMoveAngle) + rotateAngle = isSnapping + ? rotateAngleWithSnapping + : utils.fracAngle(mouseMoveAngle, null) } const rotateAngleInDegrees = utils.degrees(rotateAngle) @@ -187,13 +232,16 @@ class RotateTool implements Tool { this.editor.update(dragCtx.action, true) return true - } + }, 40) // 25fps mouseup() { if (!this.dragCtx) { return true } + this.reStruct.clearSnappingBonds() + this.editor.update(true) + const dragCtx = this.dragCtx const action = dragCtx.action @@ -222,6 +270,207 @@ class RotateTool implements Tool { mouseleave() { this.mouseup() } + + private initSnapInfo() { + if (this.centerAtomId === undefined) { + this.snapInfo = null + return + } + + const centerAtom = this.struct.atoms.get(this.centerAtomId) + const { + rotatableHalfBondIds, + rotatableHalfBondAngles, + fixedHalfBondIds, + fixedHalfBondAngles + } = this.partitionNeighborsBySelection(this.selection, centerAtom) + + // Don't support this case + if (rotatableHalfBondIds.length > 1) { + this.snapInfo = null + return + } + + const rotatableHalfBondId = rotatableHalfBondIds[0] + const rotatableHalfBondAngle = rotatableHalfBondAngles[0] + const { absoluteSnapAngles, snapAngleToHalfBonds, snapMode } = + this.calculateAbsoluteSnapAngles( + fixedHalfBondIds, + fixedHalfBondAngles, + rotatableHalfBondId + ) + + this.snapInfo = { + snapMode, + rotatableHalfBondAngle, + absoluteSnapAngles, + snapAngleToHalfBonds, + snapAngleDrawingProps: null + } + } + + private calculateAbsoluteSnapAngles( + fixedHalfBondIds: number[], + fixedHalfBondAngles: number[], + rotatableHalfBondId: number + ) { + let snapMode: SnapMode = 'one-bond' + let absoluteSnapAngles: number[] = [] + let snapAngleToHalfBonds: Map = new Map() + if (fixedHalfBondIds.length === 1) { + const fixedHalfBondId = fixedHalfBondIds[0] + const fixedHalfBondAngle = fixedHalfBondAngles[0] + ;[absoluteSnapAngles, snapAngleToHalfBonds] = + this.calculateAbsoluteAnglesByFixedBond( + fixedHalfBondId, + fixedHalfBondAngle, + rotatableHalfBondId + ) + } else if (fixedHalfBondIds.length > 1) { + snapMode = 'multiple-bonds' + ;[absoluteSnapAngles, snapAngleToHalfBonds] = + this.calculateAbsoluteAnglesByBisector( + fixedHalfBondIds, + fixedHalfBondAngles, + rotatableHalfBondId + ) + } + return { absoluteSnapAngles, snapAngleToHalfBonds, snapMode } + } + + private calculateAbsoluteAnglesByFixedBond( + fixedHalfBondId: number, + fixedHalfBondAngle: number, + rotatableHalfBondId: number + ) { + const absoluteSnapAngles: number[] = [] + const snapAngleToHalfBonds: Map = new Map() + SNAP_ANGLES_RELATIVE_TO_FIXED_BOND.forEach((angle) => { + const snapAngle = utils.normalizeAngle(fixedHalfBondAngle + angle) + absoluteSnapAngles.push(snapAngle) + snapAngleToHalfBonds.set(snapAngle, [ + rotatableHalfBondId, + fixedHalfBondId + ]) + }) + return [absoluteSnapAngles, snapAngleToHalfBonds] as const + } + + private calculateAbsoluteAnglesByBisector( + fixedHalfBondIds: number[], + fixedHalfBondAngles: number[], + rotatableHalfBondId: number + ) { + const absoluteSnapAngles: number[] = [] + const snapAngleToHalfBonds: Map = new Map() + const length = fixedHalfBondIds.length + for (let i = 0; i < length; i++) { + const previousHalfBondId = fixedHalfBondIds[i] + const previousHalfBondAngle = fixedHalfBondAngles[i] + for (let j = i + 1; j < length; j++) { + const currentHalfBondId = fixedHalfBondIds[j] + const currentHalfBondAngle = fixedHalfBondAngles[j] + const difference = currentHalfBondAngle - previousHalfBondAngle + const bisectorAngle = utils.normalizeAngle( + currentHalfBondAngle - difference / 2 + ) + const snapAngle = + difference > Math.PI + ? bisectorAngle + : utils.normalizeAngle(bisectorAngle + Math.PI) + absoluteSnapAngles.push(snapAngle) + snapAngleToHalfBonds.set(snapAngle, [ + rotatableHalfBondId, + previousHalfBondId, + currentHalfBondId + ]) + } + } + return [absoluteSnapAngles, snapAngleToHalfBonds] as const + } + + private partitionNeighborsBySelection( + selection: Selection | null, + atom?: Atom + ) { + const rotatableHalfBondIds: number[] = [] + const rotatableHalfBondAngles: number[] = [] + const fixedHalfBondIds: number[] = [] + const fixedHalfBondAngles: number[] = [] + + atom?.neighbors.forEach((halfBondId) => { + const halfBond = this.struct.halfBonds.get(halfBondId) + assert(halfBond != null) + const neighborAtomId = halfBond.end + if (selection?.atoms?.includes(neighborAtomId)) { + rotatableHalfBondIds.push(halfBondId) + rotatableHalfBondAngles.push(halfBond.ang) + } else { + fixedHalfBondIds.push(halfBondId) + fixedHalfBondAngles.push(halfBond.ang) + } + }) + + return { + rotatableHalfBondIds, + rotatableHalfBondAngles, + fixedHalfBondIds, + fixedHalfBondAngles + } + } + + private snap(mouseMoveAngle: number): [boolean, number] { + let isSnapping = false + let rotateAngle = 0 + if (!this.snapInfo) { + return [isSnapping, rotateAngle] + } + + const newRotatedHalfBondAngle = utils.normalizeAngle( + this.snapInfo.rotatableHalfBondAngle + mouseMoveAngle + ) + this.snapInfo.absoluteSnapAngles.some((snapAngle, index) => { + if (Math.abs(newRotatedHalfBondAngle - snapAngle) <= MAX_SNAP_DELTA) { + isSnapping = true + assert(this.snapInfo != null) + rotateAngle = snapAngle - this.snapInfo.rotatableHalfBondAngle + this.saveSnappingBonds(snapAngle) + this.snapInfo.snapAngleDrawingProps = { + isSnapping, + absoluteAngle: snapAngle, + relativeAngle: SNAP_ANGLES_RELATIVE_TO_FIXED_BOND[index] + } + return true + } else if ( + Math.abs(newRotatedHalfBondAngle - snapAngle) < + ANGLE_INDICATOR_VISIBLE_DELTA + ) { + assert(this.snapInfo != null) + this.snapInfo.snapAngleDrawingProps = { + isSnapping, + absoluteAngle: snapAngle, + relativeAngle: SNAP_ANGLES_RELATIVE_TO_FIXED_BOND[index] + } + return true + } + return false + }) + + return [isSnapping, rotateAngle] + } + + private saveSnappingBonds(snapAngle: number) { + const halfBondsToBeHighlighted = + this.snapInfo?.snapAngleToHalfBonds.get(snapAngle) + const bondIds = halfBondsToBeHighlighted?.map((halfBond) => { + const bondId = this.struct.getBondIdByHalfBond(halfBond) + assert(bondId != null) + return bondId + }) + bondIds?.forEach((bondId) => { + this.reStruct.addSnappingBonds(bondId) + }) + } } export default RotateTool