SketchOnFace UI (#1664)

* always enter edit mode

* initial blocking of extra code-mirror updates

* dry out code

* rejig selections

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

* clean up

* stream clean up

* update export

* sketch mode can be entered and exited for extrude faces

But has bugs

* startSketchOn working in some cases, editsketch animation working but not orientation of instersection plane etc

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

This reverts commit 406fca4c55.

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

* remove comment

* add sketch on face e2e test

* tweenCamToNegYAxis should respect reduced motion

* initial sketch on face working with test

* remove temporary toolbar button and xState flow

* un-used vars

* snapshot test tweak

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

* type tidy up

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

This reverts commit c39b8ebf95.

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

This reverts commit fecf6f490a.

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

* rename

* sketch on sketch on sketch

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

* typo

* startSketchOn Endcaps

end works, start is weird still

* clear selections for entity_ids that are not recognised

* fix sketch on end cap of second order extrustion

* tiny clean up

* fix sketch on close segment/face

* clean up 'lastCodeMirrorSelectionUpdatedFromScene'

* add code mode test for sketchOnExtrudedFace

* make end cap selection more robust

* update js artifacts for extrudes

* update kcl docs

* clean up

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Kurt Hutten
2024-03-22 10:23:04 +11:00
committed by GitHub
parent e773e932b0
commit 0e916cfd5b
63 changed files with 1860 additions and 720 deletions

View File

@ -6,7 +6,7 @@ import { v4 as uuidv4 } from 'uuid'
import { EditorSelection } from '@codemirror/state'
import { kclManager } from 'lang/KclSingleton'
import { SelectionRange } from '@uiw/react-codemirror'
import { isOverlap } from 'lib/utils'
import { getNormalisedCoordinates, isOverlap } from 'lib/utils'
import { isCursorInSketchCommandRange } from 'lang/util'
import { Program } from 'lang/wasm'
import {
@ -22,70 +22,12 @@ import {
getParentGroup,
PROFILE_START,
} from 'clientSideScene/sceneEntities'
import { Mesh } from 'three'
import { Mesh, Object3D, Object3DEventMap } from 'three'
import { AXIS_GROUP, X_AXIS } from 'clientSideScene/sceneInfra'
export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b'
export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01'
/*
How selections work is complex due to the nature that we rely on the engine
to tell what has been selected after we send a click command. But than the
app needs these selections to be based on cursors, therefore the app must
be in control of selections. On top of that because we need to set cursor
positions in code-mirror for selections, both from app logic, and still
allow the user to add multiple cursors like a normal editor, it's best to
let code mirror control cursor positions and associate those source ranges
with entity ids from code-mirror events later.
So it's a lot of back and forth. conceptually the back and forth is:
1) we send a click command to the engine
2) the engine sends back ids of entities that were clicked
3) we associate that source ranges with those ids
4) we set the codemirror selection based on those source ranges (taking
into account if the user is holding shift to add to current selections
or not). we also create and remember a SelectionRangeTypeMap
5) Code mirror fires a an event that cursors have changed, we loop through
these ranges and associate them with entity ids again with the ArtifactMap,
but also we can pick up selection types using the SelectionRangeTypeMap
6) we clear all previous selections in the engine and set the new ones
The above is less likely to get stale but below is some more details,
because this wonders all over the code-base, I've tried to centeralise it
by putting relevant utils in this file. All of the functions below are
pure with the exception of getEventForSelectWithPoint which makes a call
to the engine, but it's a query call (not mutation) so I'm okay with this.
Actual side effects that change cursors or tell the engine what's selected
are still done throughout the in their relevant parts in the codebase.
In detail:
1) Click commands are mostly sent in stream.tsx search for
"select_with_point"
2) The handler for when the engine sends back entity ids calls
getEventForSelectWithPoint, it fires an XState event to update our
selections is xstate context
3 and 4) The XState handler for the above uses handleSelectionBatch and
handleSelectionWithShift to update the selections in xstate context as
well as returning our SelectionRangeTypeMap and a codeMirror specific
event to be dispatched.
5) The codeMirror handler for changes to the cursor uses
processCodeMirrorRanges to associate the ranges back with their original
types and the entity ids (the id can vary depending on the type, as
there's only one source range for a given segment, but depending on if
the user selected the segment directly or the vertex, the id will be
different)
6) We take all of the ids and create events for the engine with
resetAndSetEngineEntitySelectionCmds
An important note is that if a user changes the cursor directly themselves
then they skip directly to step 5, And these selections get a type of
"default".
There are a few more nuances than this, but best to find them in the code.
*/
export type Axis = 'y-axis' | 'x-axis' | 'z-axis'
export type Selection = {
@ -93,7 +35,9 @@ export type Selection = {
| 'default'
| 'line-end'
| 'line-mid'
| 'face'
| 'extrude-wall'
| 'start-cap'
| 'end-cap'
| 'point'
| 'edge'
| 'line'
@ -106,15 +50,6 @@ export type Selections = {
codeBasedSelections: Selection[]
}
export interface SelectionRangeTypeMap {
[key: number]: Selection['type']
}
interface RangeAndId {
id: string
range: SourceRange
}
export async function getEventForSelectWithPoint(
{
data,
@ -139,8 +74,32 @@ export async function getEventForSelectWithPoint(
},
}
}
const sourceRange = engineCommandManager.artifactMap[data.entity_id]?.range
if (engineCommandManager.artifactMap[data.entity_id]) {
const _artifact = engineCommandManager.artifactMap[data.entity_id]
const sourceRange = _artifact?.range
if (_artifact) {
if (_artifact.commandType === 'solid3d_get_extrusion_face_info') {
if (_artifact?.additionalData)
return {
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: {
range: sourceRange,
type:
_artifact?.additionalData.info === 'end'
? 'end-cap'
: 'start-cap',
},
},
}
return {
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: { range: sourceRange, type: 'extrude-wall' },
},
}
}
return {
type: 'Set selection',
data: {
@ -148,46 +107,17 @@ export async function getEventForSelectWithPoint(
selection: { range: sourceRange, type: 'default' },
},
}
}
if (!sketchEnginePathId) return null
// selected a vertex
const res = await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'path_get_curve_uuids_for_vertices',
vertex_ids: [data.entity_id],
path_id: sketchEnginePathId,
},
})
const curveIds = res?.data?.data?.curve_ids
const ranges: RangeAndId[] = curveIds
.map(
(id: string): RangeAndId => ({
id,
range: engineCommandManager.artifactMap[id].range,
})
)
.sort((a: RangeAndId, b: RangeAndId) => a.range[0] - b.range[0])
// default to the head of the curve selected
const _sourceRange = ranges?.[0].range
const artifact = engineCommandManager.artifactMap[ranges?.[0]?.id]
if (artifact.type === 'result') {
artifact.headVertexId = data.entity_id
}
return {
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
// line-end is used to indicate that headVertexId should be sent to the engine as "selected"
// not the whole curve
selection: { range: _sourceRange, type: 'line-end' },
},
} else {
// if we don't recognise the entity, select nothing
return {
type: 'Set selection',
data: { selectionType: 'singleCodeCursor' },
}
}
}
export function getEventForSegmentSelection(
obj: any
obj: Object3D<Object3DEventMap>
): ModelingMachineEvent | null {
const group = getParentGroup(obj, [
STRAIGHT_SEGMENT,
@ -231,107 +161,54 @@ export function handleSelectionBatch({
}: {
selections: Selections
}): {
selectionRangeTypeMap: SelectionRangeTypeMap
codeMirrorSelection?: EditorSelection
engineEvents: Models['WebSocketRequest_type'][]
codeMirrorSelection: EditorSelection
otherSelections: Axis[]
updateSceneObjectColors: () => void
} {
const ranges: ReturnType<typeof EditorSelection.cursor>[] = []
const selectionRangeTypeMap: SelectionRangeTypeMap = {}
const engineEvents: Models['WebSocketRequest_type'][] =
resetAndSetEngineEntitySelectionCmds(
codeToIdSelections(selections.codeBasedSelections)
)
selections.codeBasedSelections.forEach(({ range, type }) => {
if (range?.[1]) {
ranges.push(EditorSelection.cursor(range[1]))
selectionRangeTypeMap[range[1]] = type
}
})
if (ranges.length)
return {
selectionRangeTypeMap,
engineEvents,
codeMirrorSelection: EditorSelection.create(
ranges,
selections.codeBasedSelections.length - 1
),
otherSelections: selections.otherSelections,
updateSceneObjectColors: () =>
updateSceneObjectColors(selections.codeBasedSelections),
}
return {
selectionRangeTypeMap,
codeMirrorSelection: EditorSelection.create(
[EditorSelection.cursor(kclManager.code.length)],
0
),
engineEvents,
otherSelections: selections.otherSelections,
updateSceneObjectColors: () =>
updateSceneObjectColors(selections.codeBasedSelections),
}
}
export function handleSelectionWithShift({
codeSelection,
otherSelection,
currentSelections,
isShiftDown,
}: {
codeSelection?: Selection
otherSelection?: Axis
currentSelections: Selections
isShiftDown: boolean
}): {
selectionRangeTypeMap: SelectionRangeTypeMap
otherSelections: Axis[]
codeMirrorSelection?: EditorSelection
} {
const code = kclManager.code
if (codeSelection && otherSelection) {
throw new Error('cannot have both code and other selection')
}
if (!codeSelection && !otherSelection) {
return handleSelectionBatch({
selections: {
otherSelections: [],
codeBasedSelections: [
{
range: [0, code.length ? code.length : 0],
type: 'default',
},
],
},
})
}
if (otherSelection) {
return handleSelectionBatch({
selections: {
codeBasedSelections: isShiftDown
? currentSelections.codeBasedSelections
: [
{
range: [0, code.length ? code.length : 0],
type: 'default',
},
],
otherSelections: [otherSelection],
},
})
}
const isEndOfFileDumbySelection =
currentSelections.codeBasedSelections.length === 1 &&
currentSelections.codeBasedSelections[0].range[0] === kclManager.code.length
const newCodeBasedSelections = !isShiftDown
? [codeSelection!]
: isEndOfFileDumbySelection
? [codeSelection!]
: [...currentSelections.codeBasedSelections, codeSelection!]
const selections: Selections = {
otherSelections: isShiftDown ? currentSelections.otherSelections : [],
codeBasedSelections: newCodeBasedSelections,
}
return handleSelectionBatch({ selections })
}
type SelectionToEngine = { type: Selection['type']; id: string }
export function processCodeMirrorRanges({
codeMirrorRanges,
selectionRanges,
selectionRangeTypeMap,
isShiftDown,
}: {
codeMirrorRanges: readonly SelectionRange[]
selectionRanges: Selections
selectionRangeTypeMap: SelectionRangeTypeMap
isShiftDown: boolean
}): null | {
modelingEvent: ModelingMachineEvent
@ -349,41 +226,13 @@ export function processCodeMirrorRanges({
if (!isChange) return null
const codeBasedSelections: Selections['codeBasedSelections'] =
codeMirrorRanges.map(({ from, to }) => {
if (selectionRangeTypeMap[to]) {
return {
type: selectionRangeTypeMap[to],
range: [from, to],
}
}
return {
type: 'default',
range: [from, to],
}
})
const idBasedSelections: SelectionToEngine[] = codeBasedSelections
.flatMap(({ type, range }): null | SelectionToEngine[] => {
// TODO #868: loops over all artifacts will become inefficient at a large scale
const entriesWithOverlap = Object.entries(
engineCommandManager.artifactMap || {}
).filter(([_, artifact]) => {
return artifact.range && isOverlap(artifact.range, range)
? artifact
: false
})
if (entriesWithOverlap.length) {
return entriesWithOverlap.map(([id, artifact]) => ({
type,
id:
type === 'line-end' &&
artifact.type === 'result' &&
artifact.headVertexId
? artifact.headVertexId
: id,
}))
}
return null
})
.filter(Boolean) as any
const idBasedSelections: SelectionToEngine[] =
codeToIdSelections(codeBasedSelections)
if (!selectionRanges) return null
updateSceneObjectColors(codeBasedSelections)
@ -486,24 +335,21 @@ export type CommonASTNode = {
ast: Program
}
export function buildCommonNodeFromSelection(
selectionRanges: Selections,
i: number
) {
function buildCommonNodeFromSelection(selectionRanges: Selections, i: number) {
return {
selection: selectionRanges.codeBasedSelections[i],
ast: kclManager.ast,
}
}
export function nodeHasExtrude(node: CommonASTNode) {
function nodeHasExtrude(node: CommonASTNode) {
return doesPipeHaveCallExp({
calleeName: 'extrude',
...node,
})
}
export function nodeHasClose(node: CommonASTNode) {
function nodeHasClose(node: CommonASTNode) {
return doesPipeHaveCallExp({
calleeName: 'close',
...node,
@ -521,7 +367,7 @@ export function canExtrudeSelection(selection: Selections) {
)
}
export function canExtrudeSelectionItem(selection: Selections, i: number) {
function canExtrudeSelectionItem(selection: Selections, i: number) {
const commonNode = buildCommonNodeFromSelection(selection, i)
return (
@ -547,7 +393,7 @@ export function getSelectionType(
return selection.codeBasedSelections
.map((s, i) => {
if (canExtrudeSelectionItem(selection, i)) {
return ['face', 1] as ResolvedSelectionType // This is implicitly determining what a face is, which is bad
return ['extrude-wall', 1] as ResolvedSelectionType // This is implicitly determining what a face is, which is bad
} else {
return ['other', 1] as ResolvedSelectionType
}
@ -590,3 +436,100 @@ export function canSubmitSelectionArg(
})
)
}
function codeToIdSelections(
codeBasedSelections: Selection[]
): SelectionToEngine[] {
return codeBasedSelections
.flatMap(({ type, range, ...rest }): null | SelectionToEngine[] => {
// TODO #868: loops over all artifacts will become inefficient at a large scale
const entriesWithOverlap = Object.entries(
engineCommandManager.artifactMap || {}
)
.map(([id, artifact]) => {
return artifact.range && isOverlap(artifact.range, range)
? {
artifact,
selection: { type, range, ...rest },
id,
}
: false
})
.filter(Boolean)
let bestCandidate
entriesWithOverlap.forEach((entry) => {
if (!entry) return
if (
type === 'default' &&
entry.artifact.commandType === 'extend_path'
) {
bestCandidate = entry
return
}
if (
type === 'start-cap' &&
entry.artifact.commandType === 'solid3d_get_extrusion_face_info' &&
entry?.artifact?.additionalData?.info === 'start'
) {
bestCandidate = entry
return
}
if (
type === 'end-cap' &&
entry.artifact.commandType === 'solid3d_get_extrusion_face_info' &&
entry?.artifact?.additionalData?.info === 'end'
) {
bestCandidate = entry
return
}
if (
type === 'extrude-wall' &&
entry.artifact.commandType === 'solid3d_get_extrusion_face_info'
) {
bestCandidate = entry
return
}
})
if (bestCandidate) {
const _bestCandidate = bestCandidate as {
artifact: any
selection: any
id: string
}
return [
{
type,
id: _bestCandidate.id,
},
]
}
return null
})
.filter(Boolean) as any
}
export function sendSelectEventToEngine(
e: MouseEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
el: HTMLVideoElement,
streamDimensions: { streamWidth: number; streamHeight: number }
) {
const { x, y } = getNormalisedCoordinates({
clientX: e.clientX,
clientY: e.clientY,
el,
...streamDimensions,
})
const result: Promise<Models['SelectWithPoint_type']> = engineCommandManager
.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'select_with_point',
selected_at_window: { x, y },
selection_type: 'add',
},
cmd_id: uuidv4(),
})
.then((res) => res.data.data)
return result
}