Symbols overlay (#2033)

* start of overlay work

* add new icons

* add constraint symbols

* add three dots

* add primary colours

* refactor how we get constraint info for overlays

* refactor how we get constraint info for overlays

* get symbols working for tangential arc too

* extra data on constraint info

* add initial delete

* fix types and circular dep issue after rebase

* fix quirk with horz vert line overlays

* fix setup and tear down of overlays

* remove overlays that are too small

* throttle overlay updates and prove tests selecting html instead of hardcoded px coords

* initial show overaly on segment hover

* remove overlays when tool is equipped

* dounce overlay updates

* tsc

* make higlighting robust to small changes in source ranges

* replace with variable for unconstrained values, and improve styles for popover

* background tweak

* make overlays unconstrain inputs

* fix small regression

* write query for finding related tag references

* make delete segment safe

* typo

* un used imports

* test deleteSegmentFromPipeExpression

* add getConstraintInfo test

* test removeSingleConstraintInfo

* more tests

* tsc

* add tests for overlay buttons

* rename tests

* fmt

* better naming structure

* more reliablity

* more test tweaks

* fix selection test

* add delete segments with overlays tests

* dependant tag tests for segment delet

* typo

* test clean up

* fix some perf issus

* clean up

* clean up

* make things a little more dry

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* trigger ci

* Make constraint hover popovers readable on light mode

* Touch up the new variable dialog

* Little touch-up to three-dot menu style

* fix highlight issue

* fmt

* use optional chain

* Revert "A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)"

This reverts commit be3d61e4a3.

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* disable var panel in sketch mode

* fix overlay tests after mergi in main

* test tweak

* try fix ubuntu

* fmt

* more test tweaks

* tweak

* tweaks

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Frank Noirot <frank@kittycad.io>
This commit is contained in:
Kurt Hutten
2024-05-24 20:54:42 +10:00
committed by GitHub
parent 87c551b869
commit cf52e151fb
28 changed files with 4359 additions and 216 deletions

View File

@ -1,4 +1,4 @@
import { useRef, useEffect, useState } from 'react'
import { useRef, useEffect, useState, useMemo, Fragment } from 'react'
import { useModelingContext } from 'hooks/useModelingContext'
import { cameraMouseDragGuards } from 'lib/cameraControls'
@ -6,12 +6,44 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { ARROWHEAD, DEBUG_SHOW_BOTH_SCENES } from './sceneInfra'
import { ReactCameraProperties } from './CameraControls'
import { throttle } from 'lib/utils'
import { sceneInfra } from 'lib/singletons'
import {
sceneInfra,
kclManager,
codeManager,
editorManager,
sceneEntitiesManager,
engineCommandManager,
} from 'lib/singletons'
import {
EXTRA_SEGMENT_HANDLE,
PROFILE_START,
getParentGroup,
} from './sceneEntities'
import { SegmentOverlay, SketchDetails } from 'machines/modelingMachine'
import { findUsesOfTagInPipe, getNodeFromPath } from 'lang/queryAst'
import {
CallExpression,
PathToNode,
Program,
SourceRange,
Value,
parse,
recast,
} from 'lang/wasm'
import { CustomIcon, CustomIconName } from 'components/CustomIcon'
import { ConstrainInfo } from 'lang/std/stdTypes'
import { getConstraintInfo } from 'lang/std/sketch'
import { Dialog, Popover, Transition } from '@headlessui/react'
import { LineInputsType } from 'lang/std/sketchcombos'
import toast from 'react-hot-toast'
import { InstanceProps, create } from 'react-modal-promise'
import { executeAst } from 'useStore'
import {
deleteSegmentFromPipeExpression,
makeRemoveSingleConstraintInput,
removeSingleConstraintInfo,
} from 'lang/modifyAst'
import { ActionButton } from 'components/ActionButton'
function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } {
const [isCamMoving, setIsCamMoving] = useState(false)
@ -100,17 +132,531 @@ export const ClientSideScene = ({
}
return (
<div
ref={canvasRef}
style={{ cursor: cursor }}
className={`absolute inset-0 h-full w-full transition-all duration-300 ${
hideClient ? 'opacity-0' : 'opacity-100'
} ${hideServer ? 'bg-chalkboard-10 dark:bg-chalkboard-100' : ''} ${
!hideClient && !hideServer && state.matches('Sketch')
? 'bg-chalkboard-10/80 dark:bg-chalkboard-100/80'
: ''
}`}
></div>
<>
<div
ref={canvasRef}
style={{ cursor: cursor }}
className={`absolute inset-0 h-full w-full transition-all duration-300 ${
hideClient ? 'opacity-0' : 'opacity-100'
} ${hideServer ? 'bg-chalkboard-10 dark:bg-chalkboard-100' : ''} ${
!hideClient && !hideServer && state.matches('Sketch')
? 'bg-chalkboard-10/80 dark:bg-chalkboard-100/80'
: ''
}`}
></div>
<Overlays />
</>
)
}
const Overlays = () => {
const { context } = useModelingContext()
if (context.mouseState.type === 'isDragging') return null
return (
<div className="absolute inset-0 pointer-events-none">
{Object.entries(context.segmentOverlays)
.filter((a) => a[1].visible)
.map(([pathToNodeString, overlay], index) => {
return (
<Overlay
overlay={overlay}
key={pathToNodeString}
pathToNodeString={pathToNodeString}
overlayIndex={index}
/>
)
})}
</div>
)
}
const Overlay = ({
overlay,
overlayIndex,
pathToNodeString,
}: {
overlay: SegmentOverlay
overlayIndex: number
pathToNodeString: string
}) => {
const { context, send, state } = useModelingContext()
let xAlignment = overlay.angle < 0 ? '0%' : '-100%'
let yAlignment = overlay.angle < -90 || overlay.angle >= 90 ? '0%' : '-100%'
const callExpression = getNodeFromPath<CallExpression>(
kclManager.ast,
overlay.pathToNode,
'CallExpression'
).node
const constraints = getConstraintInfo(
callExpression,
codeManager.code,
overlay.pathToNode
)
const offset = 20 // px
// We could put a boolean in settings that
const offsetAngle = 90
const xOffset =
Math.cos(((overlay.angle + offsetAngle) * Math.PI) / 180) * offset
const yOffset =
Math.sin(((overlay.angle + offsetAngle) * Math.PI) / 180) * offset
const shouldShow =
overlay.visible &&
typeof context?.segmentHoverMap?.[pathToNodeString] === 'number' &&
!(
state.matches('Sketch.Line tool') ||
state.matches('Sketch.Tangential arc to') ||
state.matches('Sketch.Rectangle tool')
)
return (
<div className={`absolute w-0 h-0`}>
<div
data-testid="segment-overlay"
data-path-to-node={pathToNodeString}
data-overlay-index={overlayIndex}
data-overlay-angle={overlay.angle}
className="pointer-events-auto absolute w-0 h-0"
style={{
transform: `translate3d(${overlay.windowCoords[0]}px, ${overlay.windowCoords[1]}px, 0)`,
}}
></div>
{shouldShow && (
<div
className={`px-0 pointer-events-auto absolute flex gap-1`}
style={{
transform: `translate3d(calc(${
overlay.windowCoords[0] + xOffset
}px + ${xAlignment}), calc(${
overlay.windowCoords[1] - yOffset
}px + ${yAlignment}), 0)`,
}}
onMouseEnter={() =>
send({
type: 'Set mouse state',
data: {
type: 'isHovering',
on: overlay.group,
},
})
}
onMouseLeave={() =>
send({
type: 'Set mouse state',
data: { type: 'idle' },
})
}
>
{constraints &&
constraints.map((constraintInfo, i) => (
<ConstraintSymbol
constrainInfo={constraintInfo}
key={i}
verticalPosition={
overlay.windowCoords[1] > window.innerHeight / 2
? 'top'
: 'bottom'
}
/>
))}
<SegmentMenu
verticalPosition={
overlay.windowCoords[1] > window.innerHeight / 2
? 'top'
: 'bottom'
}
pathToNode={overlay.pathToNode}
stdLibFnName={constraints[0]?.stdLibFnName}
/>
</div>
)}
</div>
)
}
type ConfirmModalProps = InstanceProps<boolean, boolean> & { text: string }
export const ConfirmModal = ({
isOpen,
onResolve,
onReject,
text,
}: ConfirmModalProps) => {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog
as="div"
className="relative z-10"
onClose={() => onResolve(false)}
>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="rounded relative mx-auto px-4 py-8 bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-xl w-full shadow-lg">
<div>{text}</div>
<div className="mt-8 flex justify-between">
<ActionButton
Element="button"
onClick={() => onResolve(true)}
>
Continue and unconstrain
</ActionButton>
<ActionButton
Element="button"
onClick={() => onReject(false)}
>
Cancel
</ActionButton>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
)
}
export const confirmModal = create<ConfirmModalProps, boolean, boolean>(
ConfirmModal
)
export async function deleteSegment({
pathToNode,
sketchDetails,
}: {
pathToNode: PathToNode
sketchDetails: SketchDetails | null
}) {
let modifiedAst: Program = kclManager.ast
const dependentRanges = findUsesOfTagInPipe(modifiedAst, pathToNode)
const shouldContinueSegDelete = dependentRanges.length
? await confirmModal({
text: `At least ${dependentRanges.length} segment rely on the segment you're deleting.\nDo you want to continue and unconstrain these segments?`,
isOpen: true,
})
: true
if (!shouldContinueSegDelete) return
modifiedAst = deleteSegmentFromPipeExpression(
dependentRanges,
modifiedAst,
kclManager.programMemory,
codeManager.code,
pathToNode
)
const newCode = recast(modifiedAst)
modifiedAst = parse(newCode)
const testExecute = await executeAst({
ast: modifiedAst,
useFakeExecutor: true,
engineCommandManager: engineCommandManager,
})
if (testExecute.errors.length) {
toast.error('Segment tag used outside of current Sketch. Could not delete.')
return
}
if (!sketchDetails) return
sceneEntitiesManager.updateAstAndRejigSketch(
sketchDetails.sketchPathToNode,
modifiedAst,
sketchDetails.zAxis,
sketchDetails.yAxis,
sketchDetails.origin
)
}
const SegmentMenu = ({
verticalPosition,
pathToNode,
stdLibFnName,
}: {
verticalPosition: 'top' | 'bottom'
pathToNode: PathToNode
stdLibFnName: string
}) => {
const { send } = useModelingContext()
const dependentSourceRanges = findUsesOfTagInPipe(kclManager.ast, pathToNode)
return (
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
data-testid="overlay-menu"
data-stdlib-fn-name={stdLibFnName}
className="bg-chalkboard-10 dark:bg-chalkboard-100 border !border-transparent hover:!border-chalkboard-40 dark:hover:!border-chalkboard-70 ui-open:!border-chalkboard-40 dark:ui-open:!border-chalkboard-70 h-[26px] w-[26px] rounded-sm p-0 m-0"
>
<CustomIcon name={'three-dots'} />
</Popover.Button>
<Popover.Panel
as="menu"
className={`absolute ${
verticalPosition === 'top' ? 'bottom-full' : 'top-full'
} z-10 w-36 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch px-0 py-1 bg-chalkboard-10 dark:bg-chalkboard-100 rounded-sm shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50`}
>
{/* <button className="hover:bg-white/80 bg-white/50 rounded p-1 text-nowrap">
Remove segment constraints
</button> */}
<button
className="!border-transparent rounded-sm text-left p-1 text-nowrap"
// disabled={dependentSourceRanges.length > 0}
title={
dependentSourceRanges.length > 0
? `At least ${dependentSourceRanges.length} segment rely on this segment's tag.`
: ''
}
onClick={() => {
send({ type: 'Delete segment', data: pathToNode })
}}
>
Delete Segment
</button>
</Popover.Panel>
</>
)}
</Popover>
)
}
const ConstraintSymbol = ({
constrainInfo: { type: _type, isConstrained, value, pathToNode, argPosition },
verticalPosition,
}: {
constrainInfo: ConstrainInfo
verticalPosition: 'top' | 'bottom'
}) => {
const { context, send } = useModelingContext()
const varNameMap: {
[key in ConstrainInfo['type']]: {
varName: string
displayName: string
iconName: CustomIconName
implicitConstraintDesc?: string
}
} = {
xRelative: {
varName: 'xRel',
displayName: 'X Relative',
iconName: 'xRelative',
},
xAbsolute: {
varName: 'xAbs',
displayName: 'X Absolute',
iconName: 'xAbsolute',
},
yRelative: {
varName: 'yRel',
displayName: 'Y Relative',
iconName: 'yRelative',
},
yAbsolute: {
varName: 'yAbs',
displayName: 'Y Absolute',
iconName: 'yAbsolute',
},
angle: {
varName: 'angle',
displayName: 'Angle',
iconName: 'angle',
},
length: {
varName: 'len',
displayName: 'Length',
iconName: 'dimension',
},
intersectionOffset: {
varName: 'perpDist',
displayName: 'Intersection Offset',
iconName: 'intersection-offset',
},
// implicit constraints
vertical: {
varName: '',
displayName: '',
iconName: 'vertical',
implicitConstraintDesc: 'vertically',
},
horizontal: {
varName: '',
displayName: '',
iconName: 'horizontal',
implicitConstraintDesc: 'horizontally',
},
tangentialWithPrevious: {
varName: '',
displayName: '',
iconName: 'tangent',
implicitConstraintDesc: 'tangential to previous segment',
},
// we don't render this one
intersectionTag: {
varName: '',
displayName: '',
iconName: 'dimension',
},
}
const varName =
_type in varNameMap ? varNameMap[_type as LineInputsType].varName : 'var'
const name: CustomIconName = varNameMap[_type as LineInputsType].iconName
const displayName = varNameMap[_type as LineInputsType]?.displayName
const implicitDesc =
varNameMap[_type as LineInputsType]?.implicitConstraintDesc
const node = useMemo(
() =>
getNodeFromPath<Value>(parse(recast(kclManager.ast)), pathToNode).node,
[kclManager.ast, pathToNode]
)
const range: SourceRange = node ? [node.start, node.end] : [0, 0]
if (_type === 'intersectionTag') return null
return (
<div className="relative group">
<button
data-testid="constraint-symbol"
data-is-implicit-constraint={implicitDesc ? 'true' : 'false'}
data-constraint-type={_type}
data-is-constrained={isConstrained ? 'true' : 'false'}
className={`${
implicitDesc
? 'bg-chalkboard-10 dark:bg-chalkboard-100 border-transparent border-0 rounded'
: isConstrained
? 'bg-chalkboard-10 dark:bg-chalkboard-90 dark:hover:bg-chalkboard-80 border-chalkboard-40 dark:border-chalkboard-70 rounded-sm'
: 'bg-primary/30 dark:bg-primary text-primary dark:text-chalkboard-10 dark:border-transparent group-hover:bg-primary/40 group-hover:border-primary/50 group-hover:brightness-125'
} h-[26px] w-[26px] rounded-sm relative m-0 p-0`}
onMouseEnter={() => {
editorManager.setHighlightRange(range)
}}
onMouseLeave={() => {
editorManager.setHighlightRange([0, 0])
}}
// disabled={isConstrained || !convertToVarEnabled}
// disabled={implicitDesc} TODO why does this change styles that are hard to override?
onClick={async () => {
if (!isConstrained) {
send({
type: 'Convert to variable',
data: {
pathToNode,
variableName: varName,
},
})
} else if (isConstrained) {
try {
const shallowPath = getNodeFromPath<CallExpression>(
parse(recast(kclManager.ast)),
pathToNode,
'CallExpression',
true
).shallowPath
const input = makeRemoveSingleConstraintInput(
argPosition,
shallowPath
)
if (!input || !context.sketchDetails) return
const transform = removeSingleConstraintInfo(
input,
kclManager.ast,
kclManager.programMemory
)
if (!transform) return
const { modifiedAst } = transform
kclManager.updateAst(modifiedAst, true)
} catch (e) {
console.log('error', e)
}
toast.success('Constraint removed')
}
}}
>
<CustomIcon name={name} />
</button>
<div
className={`absolute ${
verticalPosition === 'top'
? 'top-0 -translate-y-full'
: 'bottom-0 translate-y-full'
} group-hover:block hidden w-[2px] h-2 translate-x-[12px] bg-white/40`}
></div>
<div
className={`absolute ${
verticalPosition === 'top' ? 'top-0' : 'bottom-0'
} group-hover:block hidden`}
style={{
transform: `translate3d(calc(-50% + 13px), ${
verticalPosition === 'top' ? '-100%' : '100%'
}, 0)`,
}}
>
<div
className="bg-chalkboard-10 dark:bg-chalkboard-90 p-2 px-3 rounded-sm border border-solid border-chalkboard-20 dark:border-chalkboard-80 shadow-sm"
data-testid="constraint-symbol-popover"
>
{implicitDesc ? (
<div className="min-w-48">
<pre className="inline-block">
<code className="text-primary">{value}</code>
</pre>{' '}
<span>is implicitly constrained {implicitDesc}</span>
</div>
) : (
<>
<div className="flex mb-1">
<span className="text-nowrap">
<span className="font-bold">
{isConstrained ? 'Constrained' : 'Unconstrained'}
</span>
<span className="text-white/80 text-sm pl-2">
{displayName}
</span>
</span>
</div>
<div className="flex mb-1">
<span className="pr-2 whitespace-nowrap">Set to</span>
<pre>
<code className="text-primary">{value}</code>
</pre>
</div>
<div className="text-sm text-chalkboard-70 dark:text-chalkboard-40 text-nowrap">
{isConstrained
? 'Click to unconstrain with raw number'
: 'Click to constrain with variable'}
</div>
</>
)}
</div>
</div>
</div>
)
}