subagent transcript
session 17f09f89 · workflow wf_2d2ac42d
delegated task
CONTEXT — FreeCAD is ported to WebAssembly (emscripten 4.0.12, clang-22, JSPI, native wasm-EH). 3D rendering is Coin3D (Open Inventor) via Quarter, re
▸ Magik
CONTEXT — FreeCAD is ported to WebAssembly (emscripten 4.0.12, clang-22, JSPI, native wasm-EH).
3D rendering is Coin3D (Open Inventor) via Quarter, rendered to an OFFSCREEN FBO then blitted into
a Qt widget via QPainter (Qt-for-wasm has NO native GL widget compositing — the whole Qt UI is a
SOFTWARE raster surface blitted to an HTML canvas). WebGL2 via emscripten (FULL_ES3, LEGACY_GL_EMULATION
off but Coin uses a GLES2 path). COIN_VBO=0 (client vertex arrays), COIN_VERTEX_ARRAYS=1 (VA fast path
default-on for indexed face/line sets). qtFontDpi=80.
KEY SOURCE LOCATIONS (find others yourself with grep/find):
- FreeCAD 3D viewer + FBO->QPainter blit: /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/View3DInventorViewer.cpp
(also SoQtQuarterAdaptor / Quarter / SoFCVectorizeAction). The wasm viewport work is documented — grep for
__EMSCRIPTEN__ / FC_OS_WASM / grabFramebuffer / QOpenGLFramebufferObject / drawImage / readPixels in src/Gui.
- Coin3D source: locate it (find / -name SoAsciiText.cpp -o -name so_glglue.c 2>/dev/null; try
/home/magik6k/lcad-wasm/freecad-artifacts, pyside-port/build/pivy-coin, /opt/toolchains). The GLES2/immediate
path is in src/rendering/ (SoGL*.cpp) + src/glue/gl.cpp (cc_glglue). SoAsciiText is src/nodes/SoAsciiText.cpp.
- Emscripten GL emulation (the ffVertex/ffNormal3/ffSyncContext/emitImm JS functions seen in the profile are
emscripten's FIXED-FUNCTION immediate-mode GLES2 emulation): /opt/toolchains/emsdk/upstream/emscripten/src/
(library_webgl.js / library_glemu.js / lib/libglemu / GLImmediate). Confirm where ff*/emitImm come from.
- Qt raster paint (RGBA64 conversions): /opt/toolchains/qtsrc/qtbase/src/gui/painting/qdrawhelper.cpp
(storeRGBA8888FromRGBA64PM / fetchRGBA8888ToRGBA64PM / comp_func_SourceOver_rgb64). Qt-wasm backingstore:
/opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmbackingstore.cpp / qwasmwindow.cpp / qwasmscreen.cpp.
- The relink/build to test a fix: cmake build at /opt/toolchains/src/freecad-gui-build; relink via
/tmp/claude-1000/.../scratchpad/femrelink-s2.sh (this session's scratchpad). Do NOT run builds — analysis only.
THE PROFILE (bottom-up, self-time, ~10s capture during hover/pointer interaction at 3-4 fps):
Qt SOFTWARE COMPOSITING (~2.3s total):
storeRGBA8888FromRGBA64PM 363ms, fetchRGBA8888ToRGBA64PM 250ms, comp_func_SourceOver_rgb64 104ms,
fetchTransformed64<TextureBlendType 2> 224ms, qt_rectfill_nonpremul_rgba 132ms, blend_color_generic 147ms,
QSvgNode::draw 1092ms(total) / QSvgG::drawCommand 1087ms / QSvgStrokeStyle::apply / QStroker::processCurrentSubpath 124ms,
gray_raster_render 247ms, readPixels 113ms, texSubImage2D 83ms, do_mirror 33ms, QImage::copy 66ms.
3D SCENE (Coin, ~1.7s):
SoAsciiText::GLRender 314+40ms + SoAsciiTextP::setUpGlyphs + cc_glyph3d_ref + cc_flw_get_vector_advance,
immediate-mode glue ffVertex 268ms + ffNormal3 40ms + ffSyncContext 86ms + emitImm + glVertex3f/glVertex3fv,
getParameter 390ms + getError 42ms (redundant GL state queries via wasm->JS->WebGL),
bufferData 465ms (client-array re-upload every draw?), sogl_render_sphere 130ms + SoSphere::GLRender,
SoSeparator::GLRenderBelowPath 1335ms(total) / SoAction::traverse 1562ms(total) / SoSeparator::cullTest 51ms.
TIMERS: setTimeout 382ms + clearTimeout 137ms. Event: pointerenter 635ms self / 2730ms total.
You have Read/Grep/Glob/Bash (read-only: grep/find/nm ok; do NOT edit files or run builds).
Return ONLY the requested structured fields. Be concrete: cite file:line, the exact mechanism, and a
SPECIFIC code-level fix (what to change), with an impact estimate (ms saved / % of frame) and effort.
LANE: Over-rendering / redraw trigger. Event: pointerenter 635ms self / 2730ms total; Timer fired 472ms; setTimeout 382ms.
Investigate: is FreeCAD redrawing the full 3D scene (and forcing a full Qt repaint) on every pointer MOVE/ENTER even when
nothing changed (no camera move, no selection change)?
- Trace the wasm event path: pointerenter/mousemove -> Qt -> QuarterWidget/View3DInventorViewer -> SoHandleEventAction ->
does it schedule a redraw (SoSceneManager::scheduleRedraw / QuarterWidget::update) unconditionally? Highlight-on-hover
(preselection) legitimately redraws, but does a bare pointerenter with no highlight also redraw?
- setTimeout/clearTimeout churn: the wasm event loop / QWasmTimer scheduling a render timer per event. Is there a redraw
coalescing (one render per animation frame) or does each event post a new render? Could renders be throttled to
requestAnimationFrame / coalesced?
- Preselection highlight: when hovering geometry, Coin re-renders to highlight. Is the highlight a full-scene re-render or
can it use a cheaper overlay/second-pass?
Propose: coalesce/throttle redraws to one-per-frame; skip redraw on no-op pointer events; cheaper preselection. Estimate ms.
Fable
thinking
I'm tracing through the redraw trigger mechanism to understand how it works, starting with the key files involved.
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && grep -rn \"scheduleRedraw\\|QuarterWidget::update\\|redraw()\" --include=*.cpp --include=*.h | head -50", "description": "Find redraw scheduling calls"}
Fable
⚙ Bash {"command": "find / -path '*Quarter*QuarterWidget.cpp' 2>/dev/null; find / -name \"SoQtQuarterAdaptor*\" 2>/dev/null | head; find / -name \"SoSceneManager.cpp\" 2>/dev/null | head", "description": "Locate Quarter source"}
Fable
thinking
I found the Coin3D source code in FreeCAD's tree and I'm now tracing through the event and redraw mechanism to understand how it works.
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && grep -rn \"scheduleRedraw\\|actualRedraw\\|update()\\|renderCB\\|redraw\" QuarterWidget.cpp SoQtQuarterAdaptor.cpp 2>/dev/null | head -60", "description": "Find redraw …
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && grep -rln \"SoHandleEventAction\\|processEvent\\|mouseMoveEvent\\|pointerenter\" Quarter/ View3DInventorViewer.cpp 2>/dev/null", "description": "Find event handling fil…
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Quarter && grep -n \"scheduleRedraw\\|actualRedraw\\|update\\|redraw\\|processEvent\\|paintEvent\\|paintGL\\|renderScene\" QuarterWidget.cpp | head -60", "description": "g…
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Quarter/QuarterWidget.cpp", "offset": 1040, "limit": 120}
Fable
thinking
I'm examining how events propagate through the system, particularly looking at the event handler method and how mouse interactions trigger redraws around that section of the code.
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Quarter/QuarterWidget.cpp", "offset": 760, "limit": 130}
Fable
thinking
I'm tracing through the event handling path to understand whether a mousemove or pointerenter event without any preselection triggers a redraw. I need to examine how SoHandleEventAction processes these events in View3DInventorViewer and how preselection interacts with the redraw scheduling, particularly looking at the event filter mechanism and SoFCUnifiedSelection's role in this flow.
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && grep -rn \"scheduleRedraw\\|redraw\\|setPreselect\\|highlight\\|SoHandleEventAction\\|handleEvent\" SoFCUnifiedSelection.cpp 2>/dev/null | head -50", "description": "gr…
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && ls SoFCUnifiedSelection* View3DInventor* 2>/dev/null; echo \"---\"; grep -c \"\" SoFCUnifiedSelection.cpp 2>/dev/null", "description": "list selection files"}
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && find . -name \"SoFCUnifiedSelection*\"; echo \"===\"; grep -rln \"setPreselection\\|Preselect\" . 2>/dev/null | head", "description": "find selection sources"}
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Selection && grep -n \"handleEvent\\|Preselect\\|redraw\\|touch()\\|SoLocation2Event\\|highlight\\|SoFullPath\" SoFCUnifiedSelection.cpp | head -60", "description": "grep …
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Selection/SoFCUnifiedSelection.cpp", "offset": 1036, "limit": 120}
Fable
thinking
I'm seeing that every mouse motion triggers a full ray-pick traversal through the scene, and `setPreselect` calls `touch()` which marks the node as modified and schedules a redraw. The critical question is whether `touch()` fires even when the preselected element hasn't actually changed, so I need to examine the `setPreselect` implementation to understand this behavior.
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Selection/SoFCUnifiedSelection.cpp", "offset": 739, "limit": 65}
Fable
thinking
I'm checking whether `setPreselect` returns 0 when hovering over the same element repeatedly, which would indicate no change and avoid unnecessary touch events. The key logic is in lines 790-801 where if `currentHighlightPath` is already set, we need to handle the existing highlight appropriately.
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Selection && grep -n \"int SelectionSingleton::setPreselect\\|::setPreselect\" Selection.cpp | head", "description": "find setPreselect in Selection.cpp"}
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Selection/Selection.cpp", "offset": 907, "limit": 120}
Fable
thinking
I'm tracing through the preselection logic—when hovering over the same element, the comparison at line 927-929 returns -1 to indicate it's already preselected, which causes an early return at line 772 without triggering a redraw. So the preselection guard is working correctly and only redraws when the element actually changes. But the profiler is still showing significant overhead somewhere.
Looking at the pointerenter event, it's spending 635ms in Qt-side event handling and 2730ms total, which includes the full ray pick traversal even over empty space. The real question is whether the scene is redrawing on every pointer move or if the redraw sensor is properly coalescing multiple events in …
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/3rdParty/coin/src && find . -name \"SoRenderManager.cpp\" -o -name \"SoSceneManager.cpp\" | head; echo \"===\"; grep -rn \"scheduleRedraw\\|redrawshot\\|SoOneShotSensor\\|rend…
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/3rdParty/coin/src/rendering && grep -n \"scheduleRedraw\\|redrawshot\\|SoOneShotSensor\\|redrawSensorCB\\|::render\\b\\|isActive\\|schedule()\" SoRenderManager.cpp | head -40"…
Fable
thinking
So `scheduleRedraw` uses a `SoOneShotSensor` that coalesces multiple redraw requests into a single trigger on the delay queue, which then calls through to `renderCB` and ultimately `viewport()->update()` to fire the paint event. The real question is what's driving the Coin delay queue in the WebAssembly event loop.
Let me trace through how `redrawshotTriggeredCB` connects to Qt's event system and find where the `SoSensorManager` delay/timer queue gets pumped in the wasm environment — there's likely a `QuarterWidget` signal or timer mechanism orchestrating this.
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/3rdParty/coin/src/rendering/SoRenderManager.cpp", "offset": 380, "limit": 30}
Fable
thinking
I'm seeing a lot of setTimeout and clearTimeout churn in the profile—382ms and 137ms respectively—which suggests the Coin sensor's delay queue is being constantly rescheduled. I need to trace how Quarter's wasm event loop actually drives this timer mechanism, whether it's through SoSensorManager or a QTimer callback that keeps getting reset with each event.
Let me search through the FreeCAD GUI code to find where this sensor timer is actually being pumped.
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && grep -rln \"SoSensorManager\\|SoDB::getSensorManager\\|processDelayQueue\\|processTimerQueue\\|setChangedCallback\\|QuarterWidget::event\\b\" Quarter/ | head; echo \"==…
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Quarter/SensorManager.cpp"}
Fable
thinking
I'm seeing the root cause of the setTimeout/clearTimeout churn on the wasm platform. The QTimer implementation relies on emscripten's setTimeout/clearTimeout, and every time the sensor queue changes—which happens constantly during touch events and redraws—it triggers sensorQueueChanged(), which repeatedly starts and stops the timer queue timer and spawns new setTimeout(0) calls for every pending delay sensor.
The real churn loop is that idleTimeout() and delayTimeout() call sensorQueueChanged() again at the end, which reschedules the timers, creating a cycle where each redraw triggers another timer start, which fires immediately due to the 0ms delay, which processes the queue and triggers a …
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Quarter/QuarterWidget.cpp", "offset": 886, "limit": 145}
Fable
thinking
I'm tracing through the redraw callback chain — the shot trigger calls redraw, which invokes the render callback set to QuarterWidget's handler, and that ultimately calls viewport()->update() to refresh the display.
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Quarter/QuarterWidget.cpp", "offset": 1030, "limit": 12}
Fable
thinking
So the redraw cycle starts with a mouse movement that triggers an event filter, which processes the event through a series of handlers including the unified selection system, ultimately performing a full ray pick to determine what's preselected.
When something changes, it calls touch() which schedules a redraw through a coalesced mechanism — the idle timer uses setTimeout(0) to batch updates, so typically one render per animation frame as the browser coalesces pointer events. But here's the core inefficiency: every preselection change triggers a complete Coin scene re-render of the entire viewport, including all geometry and text glyphs, followed by a full framebuffer readback and composite …
Fable
⚙ Bash {"command": "cd /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui && grep -rn \"scheduleRedraw\\|->update()\\|touch()\\|setRenderCache\\|redraw\" Navigation/NavigationStyle.cpp View3DInventorViewer.cpp Quarter/SoQTQuarterAdaptor.cpp 2>/d…
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui/Navigation/NavigationStyle.cpp", "offset": 2050, "limit": 25}
Fable
thinking
The passive hover redraw is only driven by preselection and properly gated, while the active redraw happens during SPINNING/DRAGGING when the button is held. I'm tracing through the pointerenter path to understand that 635ms self time — it's likely Qt-wasm's synchronous event dispatch running the entire handler chain (pick, preselect, render) synchronously under the pointerenter event, even though setTimeout is asynchronous.
Looking at the 2730ms total under pointerenter, that includes all nested work triggered synchronously by the event handler. The ray pick action traverses the whole scene and generates primitives for text and spheres, which is expensive. But the fact that rendering calls …
Fable
I have enough to characterize the redraw-trigger path concretely. Let me capture the final analysis.
Fable
⚙ StructuredOutput {"lane": "Over-rendering / redraw trigger", "root_cause": "The passive-hover redraw path is: mousemove -> Qt -> QuarterWidget event filter -> SoHandleEventAction -> SoFCUnifiedSelection::handleEvent (SoFCUnifiedSelection.cpp:1036) -> getPic…
Fable
⚙ StructuredOutput {"lane": "Over-rendering / redraw trigger", "root_cause": "Passive-hover redraw path: mousemove -> QuarterWidget event filter -> SoHandleEventAction -> SoFCUnifiedSelection::handleEvent (SoFCUnifiedSelection.cpp:1036) -> getPickedList (full…