Skip to content

Fix: Camera becomes Perspective in sketch mode - #9200

Merged
andrewvarga merged 12 commits into
mainfrom
andrewvarga/9188/perspective-camera-bug-in-sketch-mode
Dec 16, 2025
Merged

Fix: Camera becomes Perspective in sketch mode#9200
andrewvarga merged 12 commits into
mainfrom
andrewvarga/9188/perspective-camera-bug-in-sketch-mode

Conversation

@andrewvarga

@andrewvarga andrewvarga commented Dec 5, 2025

Copy link
Copy Markdown
Contributor

See video in #9186

No code fixes in the PR yet, just starting a discussion.

Sketch mode should never use a perspective camera, yet in some cases it does switch to that. It's not too easy to reproduce it, definitely race conditions going on.

Here is the normal flow of events when dragging a segment in sketch mode:

  • onDragSegment mutates the AST -> recast -> update codemirror
  • during drag there is no writing to disk
  • onDragEnd calls writeToFile()
  • writeToFile() has a 1 second debounce delay on desktop
  • Meanwhile RouteProvider is constantly detecting file changes in useFileSystemWatcher and if there is actual change in the code it updates and re-executes it (this is what's causing the bug, the code IS different)

What I found when I reproduced the issue was that the switch to perspective camera comes from here in RouteProvider: this piece of code listens for the current file's changes on the system level, and updates + executes the new code if it changed.

I couldn't find a way to reproduce this 100%, but it does happen when I'm dragging a segment, wait about a second and then edit it again: this is when the first edit's writeToFile kicks in, which triggers the file watcher, but the second edit changed the code again, so RouterProvider thinks it has actually changed externally and updates the code <- this is the actual problem, it also calls resetCameraPosition but we shouldn't detect this as external code change in the first place.

Is this code here to support having an open ZDS and still allow people to edit files "underneath" it via other means (manual file edits, etc)? This situation in itself sounds very prone for races.
Wouldn't it be simpler and safer to ignore edits during the file being open in ZDS, or at least when in sketch mode?

Most text editors / programming IDEs also keep the in-memory state intact and if they detect external changes coming in they ask you which version you want to keep.

If we want to keep the support of editing files underneath ZDS, it's still possible to fix this issue but it's a bit more complicated so I just wanted to kick off the discussion first. We'd have to synchronize execution better, which can either be a local change that covers this case, or it might need a more architectural refactor regarding execution in general. I think @franknoirot was huddling about this recently.

Related PR, cc @nadr0 : #8115

@andrewvarga
andrewvarga requested a review from a team as a code owner December 5, 2025 16:57
@vercel

vercel Bot commented Dec 5, 2025

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
modeling-app Ready Ready Preview Comment Dec 12, 2025 3:18pm

@andrewvarga
andrewvarga marked this pull request as draft December 5, 2025 16:57
@andrewvarga

andrewvarga commented Dec 8, 2025

Copy link
Copy Markdown
Contributor Author

I thought about this a bit more, the main question is what we want to do when the file changes while we're in sketch edit mode:

  • we can ignore that, the in-memory state overrides background changed in the filesystem -> this gets around the issue in #9188
  • we keep the existing behaviour (external change overrides in-memory state) OR we let the user decide with a new dialogue: in this case we need to actually fix 9188

@franknoirot

Copy link
Copy Markdown
Contributor

but it does happen when I'm dragging a segment, wait about a second and then edit it again: this is when the first edit's writeToFile kicks in, which triggers the file watcher, but the second edit changed the code again, so RouterProvider thinks it has actually changed externally and updates the code <- this is the actual problem, it also calls resetCameraPosition but we shouldn't detect this as external code change in the first place.

Just killer debugging.

@franknoirot

Copy link
Copy Markdown
Contributor

@andrewvarga I definitely see the connection to the work I'm trying to do over in #8885 and #8880. And I agree with your assessment that this file system listening is creating this uncontrolled flow, and we've had a policy of automatically accepting these updates without really squaring that with our internal state. I think I could go for alerting the user instead, or building a more robust system for responsibly updating underneath the user, but probably not for outright ignoring edits made to the user's file system underneath.

Regardless of whether which behavior we align on, I've recently learned more about the tools available in CodeMirror to handle updates more responsibly, which I think would be good for us to employ with useFileSystemWatcher and elsewhere. Using Annotations, we can mark transactions as originating from the underlying file system, which we can then handle differently from normal edits, in whichever manner we choose. Here is an example of a Transaction that uses such an Annotation to mark itself as an "artifact graph" update. Other extensions could then listen for changes and filter for transactions that have that annotation. This is how our LSP execution extension works.

@Irev-Dev and I are gonna talk about CodeMirror later today if you want to join!

@franknoirot

Copy link
Copy Markdown
Contributor

@andrewvarga here's a little proof-of-concept CodeMirror extension that fires a basic window alert whenever a transaction is marked as originating from disk. I guess my suggestion is that once we decide on what behavior we want we use these sort of mechanisms to implement it.

Demo

Screenshare.-.2025-12-09.10_46_08.AM-compressed.mp4

@andrewvarga

Copy link
Copy Markdown
Contributor Author

@franknoirot thanks for that demo, I agree annotations look useful for us!

Wish there was a way to mark the file system change caused by us writing to disk to be able to differentiate it from external apps doing that but I think that's pretty obviously not going to be possible (file system doesn't keep track of the process which made the last edit).
We could guess it by:

  • comparing file contents (what we do now: ignore the event if the file is the same as the code as we know it)
  • rely on timing (save the last write initiated by us, if file watcher event comes in within eg. 100ms of that then it's likely the event of our own).
    Both are racing, but this second one would be more reliable though as it would work even if the user makes quick edits after the last one, this could be a potential fix for the original issue Camera becomes Perspective during sketching #9186

For valid external edits, I think we could introduce an alert in that case but we have to make sure it's not producing false alarms (edits made by ZDS), which would be super annoying.

I think this should come from our users needs. Are they going to work on version tracked .kcl files (synced via git / google drive / etc)? If yes, then it can definitely happen that they pull in a new sync while a file is open..I'm not sure they would like to have their work automatically getting lost..maybe undo would actually help in this case so they can recover it, but better to show an alert if it works reliably.
Personally when I'm in sketch mode I feel like I want to own the file during that, and block anything else from updating it under me, so a pop up could be a good way to go..

@andrewvarga

Copy link
Copy Markdown
Contributor Author

@Irev-Dev and I are gonna talk about CodeMirror later today if you want to join!

Interested in that, let me know!

@franknoirot

Copy link
Copy Markdown
Contributor

Wish there was a way to mark the file system change caused by us writing to disk to be able to differentiate it from external apps doing that but I think that's pretty obviously not going to be possible (file system doesn't keep track of the process which made the last edit).

File entries have a lastUpdated property, right? Could we save our last write timestamp and reliably compare?

@franknoirot

Copy link
Copy Markdown
Contributor

For valid external edits, I think we could introduce an alert in that case but we have to make sure it's not producing false alarms (edits made by ZDS), which would be super annoying.

I think this should come from our users needs. Are they going to work on version tracked .kcl files (synced via git / google drive / etc)? If yes, then it can definitely happen that they pull in a new sync while a file is open..I'm not sure they would like to have their work automatically getting lost..maybe undo would actually help in this case so they can recover it, but better to show an alert if it works reliably.
Personally when I'm in sketch mode I feel like I want to own the file during that, and block anything else from updating it under me, so a pop up could be a good way to go..

Yeah I'd support that. Definitely not a full window alert like I showed in my demo, but we could "freeze" the UI and provide a small confirmation dialog, basically just a designed version of that. Plugging that into my branch shouldn't be too hard if you want to have a go.

@andrewvarga

andrewvarga commented Dec 11, 2025

Copy link
Copy Markdown
Contributor Author

File entries have a lastUpdated property, right? Could we save our last write timestamp and reliably compare?

@franknoirot That's a great idea! I digged in a bit more, we have a writeCausedByAppCheckedInFileTreeFileSystemWatcher flag too aimed to ignore the filewatcher for updates initiated by ZDS.
Unfortunately it's not enough because sometimes I get 2 file change events for 1 writeToDisk. This seems to be coming from the OS, and the second one may come a full second after the writeToFile:

writeToFile
eventType change 5ms
eventType change 190ms
writeToFile
eventType change 2ms
eventType change 504ms
writeToFile
eventType change 13ms
eventType change 862ms
writeToFile
eventType change 3ms
eventType change 1317ms
writeToFile
eventType change 6ms

In other times it only triggers once as expected:

writeToFile
eventType change 4ms
writeToFile
eventType change 8ms
writeToFile
eventType change 5ms
writeToFile
eventType change 5ms
writeToFile
eventType change 2ms

I have now implemented a check for last updated metadata of the file which seems to be working well, I couldn't get the perspective camera bug to appear anymore.
That older flag is not needed technically anymore, I kept it in because it doesn't hurt and it might be useful if the file stats cannot be queried.

I fixed the return type of electron.stat and cleaned up related code.

Let me know what you think!

Not sure if I should add a test, the timing is a bit racy, so even if I can manage to nail the timing for a failure (without the fix in this PR), it will be very fragile..
Handling the actual external edits is still missing, I can add that on your branch or when it's merged, probably in the form of a pop up.

@andrewvarga
andrewvarga marked this pull request as ready for review December 11, 2025 22:27
// Re execute the file you are in because an imported file was changed
await kclManager.executeAst()
} else {
const fileNameWithExtension = getStringAfterLastSeparator(path)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just moved these down to the else branch from above, since this is the only place they're used

Comment thread src/components/RouteProvider.tsx Outdated
lastUpdatedMs < kclManager.lastWrite.time + 50
) {
// Ignore this change event, last update of the file was likely caused by us
return

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the actual fix which ignores the change even if we're close to the file's last updated timestamp.

Comment thread src/components/RouteProvider.tsx Outdated
Comment on lines +77 to +96
if (window.electron) {
try {
const stat = await window.electron.stat(path)
const lastUpdatedMs = stat?.mtimeMs
if (kclManager.lastWrite && typeof lastUpdatedMs === 'number') {
// If last write happened shortly before the file was updated, it means the file was updated by us
// Typically the delay is 2-4 ms, so we allow a 50ms margin after the write and a 2ms margin
// before the write (just for inaccuracies for the timestamp).
if (
kclManager.lastWrite.time - 2 < lastUpdatedMs &&
lastUpdatedMs < kclManager.lastWrite.time + 50
) {
// Ignore this change event, last update of the file was likely caused by us
return
}
}
} catch (e) {
console.warn('stat failed for change event', e)
}

return false
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timestamp check is applied to ALL file changes (including imported files), but kclManager.lastWrite only tracks writes to the current file. This causes incorrect behavior when imported files are modified.

Bug scenario: If the current file is written at time T, and within 50ms an imported file is modified externally, the imported file change will be incorrectly ignored because the timestamp check compares the imported file's mtime against the current file's write time.

Fix: Move the timestamp check inside the if (isCurrentFile) block (after line 99) so it only applies to changes in the current file:

const isCurrentFile = loadedProject?.file?.path === path
if (isCurrentFile) {
  if (window.electron) {
    // Timestamp check here
    try {
      const stat = await window.electron.stat(path)
      const lastUpdatedMs = stat?.mtimeMs
      if (kclManager.lastWrite && typeof lastUpdatedMs === 'number') {
        if (
          kclManager.lastWrite.time - 2 < lastUpdatedMs &&
          lastUpdatedMs < kclManager.lastWrite.time + 50
        ) {
          return
        }
      }
    } catch (e) {
      console.warn('stat failed for change event', e)
    }
    
    // Rest of current file handling...
  }
}

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was actually valid

Comment thread src/lang/KclManager.ts
// Wait one event loop to give a chance for params to be set
// Save the file to disk
this.lastWrite = {
code: this.code ?? '',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code is not used yet, I was thinking of comparing the file's current content in the file watcher agains the last written code, instead of the current code in memory, as that makes more sense.

@jtran jtran left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested this with new sketch mode, i.e. sketch solve, and it seems to fix the issue for me.

@franknoirot franknoirot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice thanks for the thoughtful fix @andrewvarga! We should just make sure @lee-at-zoo-corp has a heads up to your changes to electron.stat since he's working on the FS with his web parity work, so he keeps the spirit of your fixes here when he rebases.

@andrewvarga
andrewvarga merged commit a10d60f into main Dec 16, 2025
56 checks passed
@andrewvarga
andrewvarga deleted the andrewvarga/9188/perspective-camera-bug-in-sketch-mode branch December 16, 2025 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants