fix bug and remove flash in sketch mode (#6346)

* fix bug and remove flash

* add test

* fix tests

* fix tests
This commit is contained in:
Kurt Hutten
2025-04-17 10:10:27 +10:00
committed by GitHub
parent d9fe78171f
commit ac75181f7f
6 changed files with 171 additions and 76 deletions

View File

@ -257,6 +257,14 @@ export class SceneFixture {
await expectPixelColor(this.page, colour, coords, diff) await expectPixelColor(this.page, colour, coords, diff)
} }
expectPixelColorNotToBe = async (
colour: [number, number, number] | [number, number, number][],
coords: { x: number; y: number },
diff: number
) => {
await expectPixelColorNotToBe(this.page, colour, coords, diff)
}
get gizmo() { get gizmo() {
return this.page.locator('[aria-label*=gizmo]') return this.page.locator('[aria-label*=gizmo]')
} }
@ -278,37 +286,69 @@ function isColourArray(
return isArray(colour[0]) return isArray(colour[0])
} }
export async function expectPixelColor( type PixelColorMatchMode = 'matches' | 'differs'
export async function checkPixelColor(
page: Page, page: Page,
colour: [number, number, number] | [number, number, number][], colour: [number, number, number] | [number, number, number][],
coords: { x: number; y: number }, coords: { x: number; y: number },
diff: number diff: number,
mode: PixelColorMatchMode
) { ) {
let finalValue = colour let finalValue = colour
const isMatchMode = mode === 'matches'
const actionText = isMatchMode ? 'expecting' : 'not expecting'
const functionName = isMatchMode
? 'ExpectPixelColor'
: 'ExpectPixelColourNotToBe'
await expect await expect
.poll( .poll(
async () => { async () => {
const pixel = (await getPixelRGBs(page)(coords, 1))[0] const pixel = (await getPixelRGBs(page)(coords, 1))[0]
if (!pixel) return null if (!pixel) return null
finalValue = pixel finalValue = pixel
let matches
if (!isColourArray(colour)) { if (!isColourArray(colour)) {
return pixel.every( matches = pixel.every(
(channel, index) => Math.abs(channel - colour[index]) < diff (channel, index) => Math.abs(channel - colour[index]) < diff
) )
} } else {
return colour.some((c) => matches = colour.some((c) =>
c.every((channel, index) => Math.abs(pixel[index] - channel) < diff) c.every((channel, index) => Math.abs(pixel[index] - channel) < diff)
) )
}
return isMatchMode ? matches : !matches
}, },
{ timeout: 10_000 } { timeout: 10_000 }
) )
.toBeTruthy() .toBeTruthy()
.catch((cause) => { .catch((cause) => {
throw new Error( throw new Error(
`ExpectPixelColor: point ${JSON.stringify( `${functionName}: point ${JSON.stringify(
coords coords
)} was expecting ${colour} but got ${finalValue}`, )} was ${actionText} ${colour} but got ${finalValue}`,
{ cause } { cause }
) )
}) })
} }
export async function expectPixelColor(
page: Page,
colour: [number, number, number] | [number, number, number][],
coords: { x: number; y: number },
diff: number
) {
await checkPixelColor(page, colour, coords, diff, 'matches')
}
export async function expectPixelColorNotToBe(
page: Page,
colour: [number, number, number] | [number, number, number][],
coords: { x: number; y: number },
diff: number
) {
await checkPixelColor(page, colour, coords, diff, 'differs')
}

View File

@ -3069,10 +3069,8 @@ test.describe('manual edits during sketch mode', () => {
sketch002 = startSketchOn(extrude001, face = seg01) sketch002 = startSketchOn(extrude001, face = seg01)
profile002 = startProfileAt([83.39, 329.15], sketch002) profile002 = startProfileAt([83.39, 329.15], sketch002)
|> angledLine(angle = 0, length = 119.61, tag = $rectangleSegmentA001) |> angledLine(angle = 0, length = 119.61, tag = $rectangleSegmentA001)
|> angledLine(angle = segAng(rectangleSegmentA001) - 90, length = 156.54, angle = -28) |> angledLine(length = 156.54, angle = -28)
|> angledLine( |> angledLine(
angle = segAng(rectangleSegmentA001),
length = -segLen(rectangleSegmentA001),
angle = -151, angle = -151,
length = 116.27, length = 116.27,
) )
@ -3112,14 +3110,23 @@ test.describe('manual edits during sketch mode', () => {
await test.step('Edit sketch by dragging handle', async () => { await test.step('Edit sketch by dragging handle', async () => {
await page.waitForTimeout(500) await page.waitForTimeout(500)
await expect
.poll(async () => {
await editor.expectEditor.toContain('length = 156.54, angle = -28') await editor.expectEditor.toContain('length = 156.54, angle = -28')
await page.mouse.move(handle1Location.x, handle1Location.y) await page.mouse.move(handle1Location.x, handle1Location.y)
await page.mouse.down() await page.mouse.down()
await page.mouse.move(handle1Location.x + 50, handle1Location.y + 50, { await page.mouse.move(
handle1Location.x + 50,
handle1Location.y + 50,
{
steps: 5, steps: 5,
}) }
)
await page.mouse.up() await page.mouse.up()
await editor.expectEditor.toContain('length = 231.59, angle = -34') await editor.expectEditor.toContain('length = 231.59, angle = -34')
return true
})
.toBeTruthy()
// await page.waitForTimeout(1000) // Wait for update // await page.waitForTimeout(1000) // Wait for update
}) })
@ -3136,6 +3143,8 @@ test.describe('manual edits during sketch mode', () => {
await test.step('Edit sketch again', async () => { await test.step('Edit sketch again', async () => {
await editor.expectEditor.toContain('length = 231.59, angle = -34') await editor.expectEditor.toContain('length = 231.59, angle = -34')
await page.waitForTimeout(500) await page.waitForTimeout(500)
await expect
.poll(async () => {
await page.mouse.move(handle2Location.x, handle2Location.y) await page.mouse.move(handle2Location.x, handle2Location.y)
await page.mouse.down() await page.mouse.down()
await page.mouse.move(handle2Location.x, handle2Location.y - 50, { await page.mouse.move(handle2Location.x, handle2Location.y - 50, {
@ -3143,6 +3152,9 @@ test.describe('manual edits during sketch mode', () => {
}) })
await page.mouse.up() await page.mouse.up()
await editor.expectEditor.toContain('length = 167.36, angle = -14') await editor.expectEditor.toContain('length = 167.36, angle = -14')
return true
})
.toBeTruthy()
}) })
await test.step('add whole other sketch before current sketch', async () => { await test.step('add whole other sketch before current sketch', async () => {
@ -3159,6 +3171,9 @@ test.describe('manual edits during sketch mode', () => {
const handle3Location = { x: 844, y: 212 } const handle3Location = { x: 844, y: 212 }
await test.step('edit sketch again', async () => { await test.step('edit sketch again', async () => {
await page.waitForTimeout(500) // Wait for deferred execution
await expect
.poll(async () => {
await editor.expectEditor.toContain('length = 167.36, angle = -14') await editor.expectEditor.toContain('length = 167.36, angle = -14')
await page.mouse.move(handle3Location.x, handle3Location.y) await page.mouse.move(handle3Location.x, handle3Location.y)
await page.mouse.down() await page.mouse.down()
@ -3167,6 +3182,9 @@ test.describe('manual edits during sketch mode', () => {
}) })
await page.mouse.up() await page.mouse.up()
await editor.expectEditor.toContain('length = 219.2, angle = -56') await editor.expectEditor.toContain('length = 219.2, angle = -56')
return true
})
.toBeTruthy()
}) })
// exit sketch and assert whole code // exit sketch and assert whole code
@ -3174,32 +3192,27 @@ test.describe('manual edits during sketch mode', () => {
await toolbar.exitSketch() await toolbar.exitSketch()
await editor.expectEditor.toContain( await editor.expectEditor.toContain(
`myVar1 = 5 `myVar1 = 5
sketch003 = startSketchOn(XY) sketch003 = startSketchOn(XY)
profile004 = circle(sketch003, center = [143.91, 136.89], radius = 71.63) profile004 = circle(sketch003, center = [143.91, 136.89], radius = 71.63)
sketch001 = startSketchOn(XZ) sketch001 = startSketchOn(XZ)
profile001 = startProfileAt([106.68, 89.77], sketch001) profile001 = startProfileAt([106.68, 89.77], sketch001)
|> line(end = [132.34, 157.8]) |> line(end = [132.34, 157.8])
|> line(end = [67.65, -460.55], tag = $seg01) |> line(end = [67.65, -460.55], tag = $seg01)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)]) |> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close() |> close()
extrude001 = extrude(profile001, length = 500) extrude001 = extrude(profile001, length = 500)
sketch002 = startSketchOn(extrude001, face = seg01) sketch002 = startSketchOn(extrude001, face = seg01)
profile002 = startProfileAt([83.39, 329.15], sketch002) profile002 = startProfileAt([83.39, 329.15], sketch002)
|> angledLine(angle = 0, length = 119.61, tag = $rectangleSegmentA001) |> angledLine(angle = 0, length = 119.61, tag = $rectangleSegmentA001)
|> angledLine(angle = segAng(rectangleSegmentA001) - 90, length = 219.2, angle = -56) |> angledLine(length = 219.2, angle = -56)
|> angledLine( |> angledLine(angle = -151, length = 116.27)
angle = segAng(rectangleSegmentA001),
length = -segLen(rectangleSegmentA001),
angle = -151,
length = 116.27,
)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)]) |> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close() |> close()
profile003 = startProfileAt([-201.08, 254.17], sketch002) profile003 = startProfileAt([-201.08, 254.17], sketch002)
|> line(end = [103.55, 33.32]) |> line(end = [103.55, 33.32])
|> line(end = [48.8, -153.54]) |> line(end = [48.8, -153.54])
`, `,
{ shouldNormalise: true } { shouldNormalise: true }
) )
await editor.expectState({ await editor.expectState({
@ -3231,10 +3244,8 @@ test.describe('manual edits during sketch mode', () => {
sketch002 = startSketchOn(extrude001, face = seg01) sketch002 = startSketchOn(extrude001, face = seg01)
profile002 = startProfileAt([83.39, 329.15], sketch002) profile002 = startProfileAt([83.39, 329.15], sketch002)
|> angledLine(angle = 0, length = 119.61, tag = $rectangleSegmentA001) |> angledLine(angle = 0, length = 119.61, tag = $rectangleSegmentA001)
|> angledLine(angle = segAng(rectangleSegmentA001) - 90, length = 156.54, angle = -28) |> angledLine(length = 156.54, angle = -28)
|> angledLine( |> angledLine(
angle = segAng(rectangleSegmentA001),
length = -segLen(rectangleSegmentA001),
angle = -151, angle = -151,
length = 116.27, length = 116.27,
) )
@ -3350,7 +3361,21 @@ test.describe('manual edits during sketch mode', () => {
// this checks sketch segments have been drawn // this checks sketch segments have been drawn
await verifyArrowHeadColor(arrowHeadWhite) await verifyArrowHeadColor(arrowHeadWhite)
}) })
await page.waitForTimeout(100)
await test.step('make a change to the code and expect pixel color to change', async () => {
// defends against a regression where sketch would duplicate in the scene
// https://github.com/KittyCAD/modeling-app/issues/6345
await editor.replaceCode(
'startProfileAt([75.8, 317.2',
'startProfileAt([75.8, 217.2'
)
// expect not white anymore
await scene.expectPixelColorNotToBe(
TEST_COLORS.WHITE,
arrowHeadLocation,
15
)
})
} }
) )
}) })

View File

@ -669,7 +669,10 @@ export class SceneEntities {
variableDeclarationName: string variableDeclarationName: string
}> { }> {
const prepared = this.prepareTruncatedAst(sketchNodePaths, maybeModdedAst) const prepared = this.prepareTruncatedAst(sketchNodePaths, maybeModdedAst)
if (err(prepared)) return Promise.reject(prepared) if (err(prepared)) {
this.tearDownSketch({ removeAxis: false })
return Promise.reject(prepared)
}
const { truncatedAst, variableDeclarationName } = prepared const { truncatedAst, variableDeclarationName } = prepared
const { execState } = await executeAstMock({ const { execState } = await executeAstMock({
@ -695,6 +698,8 @@ export class SceneEntities {
const scale = this.sceneInfra.getClientSceneScaleFactor(dummy) const scale = this.sceneInfra.getClientSceneScaleFactor(dummy)
const callbacks: (() => SegmentOverlayPayload | null)[] = [] const callbacks: (() => SegmentOverlayPayload | null)[] = []
this.sceneInfra.pauseRendering()
this.tearDownSketch({ removeAxis: false })
for (const sketchInfo of sketchesInfo) { for (const sketchInfo of sketchesInfo) {
const { sketch } = sketchInfo const { sketch } = sketchInfo
@ -766,7 +771,11 @@ export class SceneEntities {
segPathToNode, segPathToNode,
['CallExpression', 'CallExpressionKw'] ['CallExpression', 'CallExpressionKw']
) )
if (err(_node1)) return if (err(_node1)) {
this.tearDownSketch({ removeAxis: false })
this.sceneInfra.resumeRendering()
return
}
const callExpName = _node1.node?.callee?.name.name const callExpName = _node1.node?.callee?.name.name
const initSegment = const initSegment =
@ -862,6 +871,9 @@ export class SceneEntities {
) )
position && this.intersectionPlane.position.set(...position) position && this.intersectionPlane.position.set(...position)
this.sceneInfra.scene.add(group) this.sceneInfra.scene.add(group)
this.sceneInfra.resumeRendering()
this.sceneInfra.camControls.enableRotate = false this.sceneInfra.camControls.enableRotate = false
this.sceneInfra.overlayCallbacks(callbacks) this.sceneInfra.overlayCallbacks(callbacks)
@ -967,6 +979,8 @@ export class SceneEntities {
position: origin, position: origin,
maybeModdedAst: modifiedAst, maybeModdedAst: modifiedAst,
draftExpressionsIndices, draftExpressionsIndices,
}).catch(() => {
return { truncatedAst: modifiedAst }
}) })
this.sceneInfra.setCallbacks({ this.sceneInfra.setCallbacks({
onClick: async (args) => { onClick: async (args) => {

View File

@ -264,6 +264,8 @@ export class SceneInfra {
hasBeenDragged: boolean hasBeenDragged: boolean
} | null = null } | null = null
mouseDownVector: null | Vector2 = null mouseDownVector: null | Vector2 = null
private isRenderingPaused = false
private lastFrameTime = 0
constructor(engineCommandManager: EngineCommandManager) { constructor(engineCommandManager: EngineCommandManager) {
// SCENE // SCENE
@ -348,8 +350,20 @@ export class SceneInfra {
TWEEN.update() // This will update all tweens during the animation loop TWEEN.update() // This will update all tweens during the animation loop
if (!this.isFovAnimationInProgress) { if (!this.isFovAnimationInProgress) {
this.camControls.update() this.camControls.update()
// If rendering is paused, only render if enough time has passed to maintain smooth animation
if (this.isRenderingPaused) {
const currentTime = performance.now()
if (currentTime - this.lastFrameTime > 1000 / 30) {
// Limit to 30fps while paused
this.renderer.render(this.scene, this.camControls.camera) this.renderer.render(this.scene, this.camControls.camera)
this.labelRenderer.render(this.scene, this.camControls.camera) this.labelRenderer.render(this.scene, this.camControls.camera)
this.lastFrameTime = currentTime
}
} else {
this.renderer.render(this.scene, this.camControls.camera)
this.labelRenderer.render(this.scene, this.camControls.camera)
}
} }
} }
@ -685,6 +699,15 @@ export class SceneInfra {
const scale = this.getClientSceneScaleFactor(dummy) const scale = this.getClientSceneScaleFactor(dummy)
return getLength(a, b) / scale return getLength(a, b) / scale
} }
pauseRendering() {
this.isRenderingPaused = true
// Store the current time to prevent unnecessary updates
this.lastFrameTime = performance.now()
}
resumeRendering() {
this.isRenderingPaused = false
}
} }
function baseUnitTomm(baseUnit: BaseUnit) { function baseUnitTomm(baseUnit: BaseUnit) {

View File

@ -1612,9 +1612,6 @@ export const ModelingMachineProvider = ({
async ({ input: { sketchDetails, selectionRanges } }) => { async ({ input: { sketchDetails, selectionRanges } }) => {
if (!sketchDetails) return if (!sketchDetails) return
if (!sketchDetails.sketchEntryNodePath?.length) return if (!sketchDetails.sketchEntryNodePath?.length) return
if (Object.keys(sceneEntitiesManager.activeSegments).length > 0) {
sceneEntitiesManager.tearDownSketch({ removeAxis: false })
}
sceneInfra.resetMouseListeners() sceneInfra.resetMouseListeners()
await sceneEntitiesManager.setupSketch({ await sceneEntitiesManager.setupSketch({
sketchEntryNodePath: sketchDetails?.sketchEntryNodePath || [], sketchEntryNodePath: sketchDetails?.sketchEntryNodePath || [],

View File

@ -3792,11 +3792,7 @@ export const modelingMachine = setup({
}, },
initial: 'splitting sketch pipe', initial: 'splitting sketch pipe',
entry: [ entry: ['assign tool in context', 'reset selections'],
'assign tool in context',
'reset selections',
'tear down client sketch',
],
}, },
'Circle three point tool': { 'Circle three point tool': {
states: { states: {