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 commit406fca4c55
. * 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 commitc39b8ebf95
. * Revert "A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)" This reverts commitfecf6f490a
. * 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:
@ -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
|
||||
}
|
||||
|
Reference in New Issue
Block a user