From 098e8e2d0474cce4ec9d5c84ca0dec3f296beaa0 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 28 Apr 2026 17:57:13 +0200 Subject: [PATCH] Feat: Add update loop tracing utilities for debugging state changes --- src/debug/update-loop-trace.ts | 360 +++++++++++++++++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 src/debug/update-loop-trace.ts diff --git a/src/debug/update-loop-trace.ts b/src/debug/update-loop-trace.ts new file mode 100644 index 00000000..9b3cae0d --- /dev/null +++ b/src/debug/update-loop-trace.ts @@ -0,0 +1,360 @@ +export type UpdateLoopTracePayload = Record; + +interface UpdateLoopTraceEvent { + label: string; + timestamp: number; + payload: UpdateLoopTracePayload; +} + +interface UpdateLoopCameraState { + target: { + x: number; + y: number; + z: number; + }; + perspectiveOrbit: { + radius: number; + theta: number; + phi: number; + }; + orthographicZoom: number; +} + +interface UpdateLoopSelection { + kind: string; + ids?: readonly string[]; + brushId?: string; + faceId?: string; + edgeId?: string; + vertexId?: string; + pathId?: string; + pointId?: string; +} + +const TRACE_WINDOW_MS = 1000; +const TRACE_LABEL_THRESHOLD = 20; +const TRACE_TOTAL_THRESHOLD = 50; +const TRACE_MAX_EVENTS = 250; +const TRACE_WARNING_THROTTLE_MS = 750; +const TRACE_PAYLOAD_STRING_LIMIT = 180; +const TRACE_TABLE_PAYLOAD_LIMIT = 360; + +const traceEvents: UpdateLoopTraceEvent[] = []; +let lastWarningTime = 0; + +export function isUpdateLoopTraceEnabled(): boolean { + if (!import.meta.env.DEV || typeof window === "undefined") { + return false; + } + + try { + const queryEnabled = + new URLSearchParams(window.location.search).get("debugUpdateLoop") === + "1"; + const storageEnabled = + window.localStorage.getItem("webeditor3d.debugUpdateLoop") === "1"; + + return queryEnabled || storageEnabled; + } catch { + return false; + } +} + +export function traceUpdateLoopEvent( + label: string, + payload: UpdateLoopTracePayload = {} +): void { + if (!isUpdateLoopTraceEnabled()) { + return; + } + + const timestamp = getTraceNow(); + const summarizedPayload = summarizeTracePayload(payload); + + traceEvents.push({ + label, + timestamp, + payload: summarizedPayload + }); + + pruneTraceEvents(timestamp); + maybeWarnForHighFrequencyUpdates(label, timestamp); +} + +export function summarizeUpdateLoopCameraState( + cameraState: UpdateLoopCameraState | null | undefined +): UpdateLoopTracePayload | null { + if (cameraState === null || cameraState === undefined) { + return null; + } + + return { + target: { + x: roundTraceNumber(cameraState.target.x), + y: roundTraceNumber(cameraState.target.y), + z: roundTraceNumber(cameraState.target.z) + }, + perspectiveOrbit: { + radius: roundTraceNumber(cameraState.perspectiveOrbit.radius), + theta: roundTraceNumber(cameraState.perspectiveOrbit.theta), + phi: roundTraceNumber(cameraState.perspectiveOrbit.phi) + }, + orthographicZoom: roundTraceNumber(cameraState.orthographicZoom) + }; +} + +export function summarizeUpdateLoopCameraStateDeltas( + previousCameraState: UpdateLoopCameraState | null | undefined, + nextCameraState: UpdateLoopCameraState | null | undefined +): UpdateLoopTracePayload | null { + if ( + previousCameraState === null || + previousCameraState === undefined || + nextCameraState === null || + nextCameraState === undefined + ) { + return null; + } + + return { + targetX: roundTraceNumber( + nextCameraState.target.x - previousCameraState.target.x + ), + targetY: roundTraceNumber( + nextCameraState.target.y - previousCameraState.target.y + ), + targetZ: roundTraceNumber( + nextCameraState.target.z - previousCameraState.target.z + ), + radius: roundTraceNumber( + nextCameraState.perspectiveOrbit.radius - + previousCameraState.perspectiveOrbit.radius + ), + theta: roundTraceNumber( + nextCameraState.perspectiveOrbit.theta - + previousCameraState.perspectiveOrbit.theta + ), + phi: roundTraceNumber( + nextCameraState.perspectiveOrbit.phi - + previousCameraState.perspectiveOrbit.phi + ), + orthographicZoom: roundTraceNumber( + nextCameraState.orthographicZoom - previousCameraState.orthographicZoom + ) + }; +} + +export function summarizeUpdateLoopSelection( + selection: UpdateLoopSelection | null | undefined +): UpdateLoopTracePayload | null { + if (selection === null || selection === undefined) { + return null; + } + + switch (selection.kind) { + case "none": + return { + kind: selection.kind + }; + case "brushFace": + return { + kind: selection.kind, + brushId: selection.brushId ?? null, + faceId: selection.faceId ?? null + }; + case "brushEdge": + return { + kind: selection.kind, + brushId: selection.brushId ?? null, + edgeId: selection.edgeId ?? null + }; + case "brushVertex": + return { + kind: selection.kind, + brushId: selection.brushId ?? null, + vertexId: selection.vertexId ?? null + }; + case "pathPoint": + return { + kind: selection.kind, + pathId: selection.pathId ?? null, + pointId: selection.pointId ?? null + }; + default: + return { + kind: selection.kind, + ids: summarizeStringList(selection.ids ?? []) + }; + } +} + +function getTraceNow(): number { + return typeof performance === "undefined" ? Date.now() : performance.now(); +} + +function pruneTraceEvents(timestamp: number): void { + const oldestAllowedTimestamp = timestamp - TRACE_WINDOW_MS; + + while ( + traceEvents.length > 0 && + traceEvents[0].timestamp < oldestAllowedTimestamp + ) { + traceEvents.shift(); + } + + while (traceEvents.length > TRACE_MAX_EVENTS) { + traceEvents.shift(); + } +} + +function maybeWarnForHighFrequencyUpdates( + triggerLabel: string, + timestamp: number +): void { + if (timestamp - lastWarningTime < TRACE_WARNING_THROTTLE_MS) { + return; + } + + const countsByLabel = new Map(); + const lastEventByLabel = new Map(); + + for (const event of traceEvents) { + countsByLabel.set(event.label, (countsByLabel.get(event.label) ?? 0) + 1); + lastEventByLabel.set(event.label, event); + } + + const triggerLabelCount = countsByLabel.get(triggerLabel) ?? 0; + const labelExceeded = triggerLabelCount > TRACE_LABEL_THRESHOLD; + const totalExceeded = traceEvents.length > TRACE_TOTAL_THRESHOLD; + + if (!labelExceeded && !totalExceeded) { + return; + } + + lastWarningTime = timestamp; + + const rows = [...countsByLabel.entries()] + .sort((left, right) => right[1] - left[1]) + .map(([label, count]) => { + const lastEvent = lastEventByLabel.get(label); + + return { + source: label, + countInLastMs: count, + lastAtMs: lastEvent?.timestamp.toFixed(1) ?? "", + lastPayload: + lastEvent === undefined + ? "" + : stringifyTracePayloadForTable(lastEvent.payload) + }; + }); + + console.groupCollapsed("[update-loop-trace] high frequency updates", { + triggerLabel, + triggerLabelCount, + totalCount: traceEvents.length, + windowMs: TRACE_WINDOW_MS + }); + console.table(rows); + console.log( + "Last payloads", + Object.fromEntries( + [...lastEventByLabel.entries()].map(([label, event]) => [ + label, + event.payload + ]) + ) + ); + + if (labelExceeded) { + console.trace(`[update-loop-trace] threshold source: ${triggerLabel}`); + } + + console.groupEnd(); +} + +function summarizeTracePayload( + payload: UpdateLoopTracePayload +): UpdateLoopTracePayload { + return summarizeTraceValue(payload, 0) as UpdateLoopTracePayload; +} + +function summarizeTraceValue(value: unknown, depth: number): unknown { + if ( + value === null || + value === undefined || + typeof value === "boolean" + ) { + return value; + } + + if (typeof value === "number") { + return Number.isFinite(value) ? roundTraceNumber(value) : String(value); + } + + if (typeof value === "string") { + return value.length <= TRACE_PAYLOAD_STRING_LIMIT + ? value + : `${value.slice(0, TRACE_PAYLOAD_STRING_LIMIT)}...`; + } + + if (typeof value !== "object") { + return String(value); + } + + if (Array.isArray(value)) { + if (depth >= 3) { + return `[array length=${value.length}]`; + } + + const summarized = value + .slice(0, 8) + .map((item) => summarizeTraceValue(item, depth + 1)); + + if (value.length > summarized.length) { + summarized.push(`... ${value.length - summarized.length} more`); + } + + return summarized; + } + + if (depth >= 3) { + return "[object]"; + } + + const result: Record = {}; + const entries = Object.entries(value).slice(0, 16); + + for (const [key, entryValue] of entries) { + result[key] = summarizeTraceValue(entryValue, depth + 1); + } + + const extraKeyCount = Object.keys(value).length - entries.length; + + if (extraKeyCount > 0) { + result.__extraKeys = extraKeyCount; + } + + return result; +} + +function summarizeStringList(values: readonly string[]): UpdateLoopTracePayload { + return { + count: values.length, + firstIds: values.slice(0, 5) + }; +} + +function stringifyTracePayloadForTable(payload: UpdateLoopTracePayload): string { + const json = JSON.stringify(payload); + + if (json.length <= TRACE_TABLE_PAYLOAD_LIMIT) { + return json; + } + + return `${json.slice(0, TRACE_TABLE_PAYLOAD_LIMIT)}...`; +} + +function roundTraceNumber(value: number): number { + return Number(value.toFixed(6)); +}