From 99babfacaf4d1c3ccebd05ed12108bd82a8ab45d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 26 Jul 2026 20:41:48 +0900 Subject: [PATCH] auto: 2026-07-26 20:41 (ESD_LAPTOP) --- .../screenCanvas.drawController.ts | 23 ++++ .../openwebcad/src/helpers/draw.ts | 21 ++- .../openwebcad/src/helpers/scene-cache.ts | 121 ++++++++++++++++++ .../openwebcad/src/helpers/scene-version.ts | 13 ++ B07_wf4_DesignDetail/openwebcad/src/main.tsx | 28 +++- B07_wf4_DesignDetail/openwebcad/src/state.ts | 7 + 6 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts create mode 100644 B07_wf4_DesignDetail/openwebcad/src/helpers/scene-version.ts diff --git a/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 2b5fd28c..2eec4a8b 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -238,6 +238,29 @@ export class ScreenCanvasDrawController implements DrawController { this.context.fillStyle = fillColor; } + /** + * Temporarily redirect all draw calls to another 2d context (eg an + * offscreen canvas used as static scene cache), reusing the current + * offset/scale/canvasSize without touching state or react triggers. + */ + public withContext(temporaryContext: CanvasRenderingContext2D, renderFunction: () => void) { + const originalContext = this.context; + this.context = temporaryContext; + try { + renderFunction(); + } finally { + this.context = originalContext; + } + } + + /** + * Blit a pre-rendered bitmap (static scene cache) onto the canvas at a + * pixel offset. Used while panning to avoid re-stroking every entity. + */ + public blitImage(source: CanvasImageSource, dx: number, dy: number) { + this.context.drawImage(source, dx, dy); + } + public clear() { if (this.canvasSize === null) return; diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/draw.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/draw.ts index d86bcc8a..337be55c 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/helpers/draw.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/draw.ts @@ -14,6 +14,7 @@ import { getDebugEntities, getEntities, getGhostHelperEntities, + getHighlightedEntityIds, getHoveredSnapPoints, getInputController, getShouldDrawCursor, @@ -21,13 +22,31 @@ import { getSnapPointOnAngleGuide, } from '../state'; import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; +import { drawScene } from './scene-cache'; + +/** + * Hover highlight is excluded from the static scene cache (it changes every + * mouse move) — re-draw the few highlighted entities on top of the blit. + */ +function drawHighlightedEntities(drawController: ScreenCanvasDrawController) { + const highlightedIds = getHighlightedEntityIds(); + if (!highlightedIds.length) return; + const idSet = new Set(highlightedIds); + drawEntities( + drawController, + getEntities().filter(entity => idSet.has(entity.id)), + ); +} export function draw(drawController: ScreenCanvasDrawController) { drawController.clear(); + // Static scene (all entities): cached bitmap blit, rebuilt only when needed. + drawScene(drawController, performance.now()); + drawHighlightedEntities(drawController); + drawHelpers(drawController, getAngleGuideEntities()); drawEntities(drawController, getGhostHelperEntities()); - drawEntities(drawController, getEntities()); drawDebugEntities(drawController, getDebugEntities()); const { snapPoint: closestSnapPoint } = getClosestSnapPoint( diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts new file mode 100644 index 00000000..f96d2d9e --- /dev/null +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts @@ -0,0 +1,121 @@ +import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; +import { + getEntities, + getGridEnabled, + getHighlightedEntityIds, + setHighlightedEntityIds, +} from '../state'; +import { drawEntities } from './draw-functions'; +import { getSceneVersion } from './scene-version'; + +/** + * Static scene cache: all entities are rendered once into an offscreen canvas. + * While panning, the cached bitmap is blitted at a pixel offset instead of + * re-stroking every entity each frame. The cache is rebuilt when the scene + * version bumps (entities/layers/selection changed), when zoom or canvas size + * changes, or after the pan offset has settled for PAN_SETTLE_MS. + * + * Highlight is intentionally NOT baked into the cache (it changes on every + * mouse move) — draw.ts re-draws highlighted entities on top each frame. + */ +const PAN_SETTLE_MS = 120; + +interface RenderedParams { + version: number; + scale: number; + offsetX: number; + offsetY: number; + sizeX: number; + sizeY: number; +} + +let offscreenCanvas: HTMLCanvasElement | null = null; +let rendered: RenderedParams | null = null; +let prevOffsetX = Number.NaN; +let prevOffsetY = Number.NaN; +let lastOffsetChangeAt = 0; + +export const scenePerf = { + sceneRebuilds: 0, + lastRebuildMs: 0, + avgFrameMs: 0, +}; +(window as unknown as Record).__aisloCadPerf = scenePerf; + +export function invalidateSceneCache(): void { + rendered = null; +} + +function rebuildScene(drawController: ScreenCanvasDrawController): void { + const size = drawController.getCanvasSize(); + if (!offscreenCanvas) { + offscreenCanvas = document.createElement('canvas'); + } + if (offscreenCanvas.width !== size.x || offscreenCanvas.height !== size.y) { + offscreenCanvas.width = Math.max(1, size.x); + offscreenCanvas.height = Math.max(1, size.y); + } + const offscreenContext = offscreenCanvas.getContext('2d'); + if (!offscreenContext) return; + + const startedAt = performance.now(); + // Exclude the (rapidly changing) hover highlight from the baked bitmap. + const savedHighlight = getHighlightedEntityIds(); + if (savedHighlight.length) setHighlightedEntityIds([]); + drawController.withContext(offscreenContext, () => { + drawController.clear(); + drawEntities(drawController, getEntities()); + }); + if (savedHighlight.length) setHighlightedEntityIds(savedHighlight); + + scenePerf.lastRebuildMs = performance.now() - startedAt; + scenePerf.sceneRebuilds++; + + const offset = drawController.getScreenOffset(); + rendered = { + version: getSceneVersion(), + scale: drawController.getScreenScale(), + offsetX: offset.x, + offsetY: offset.y, + sizeX: size.x, + sizeY: size.y, + }; +} + +/** + * Draw the static scene: rebuild the cache when needed, otherwise blit the + * cached bitmap (shifted by the pan delta). Called once per frame by draw(). + */ +export function drawScene(drawController: ScreenCanvasDrawController, now: number): void { + const size = drawController.getCanvasSize(); + const scale = drawController.getScreenScale(); + const offset = drawController.getScreenOffset(); + + if (offset.x !== prevOffsetX || offset.y !== prevOffsetY) { + lastOffsetChangeAt = now; + prevOffsetX = offset.x; + prevOffsetY = offset.y; + } + + const paramsChanged = + !rendered || + rendered.version !== getSceneVersion() || + rendered.scale !== scale || + rendered.sizeX !== size.x || + rendered.sizeY !== size.y; + const offsetChanged = + !!rendered && (rendered.offsetX !== offset.x || rendered.offsetY !== offset.y); + + // Grid lines are screen-fixed (drawn in clear()), so blitting a shifted + // bitmap would drag the grid along — always re-render while grid is on. + if (paramsChanged || getGridEnabled() || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) { + rebuildScene(drawController); + } + + if (!offscreenCanvas || !rendered) return; + // screenX = (worldX - offsetX) * scale, canvasY is y-flipped afterwards: + // content shifts left when offset.x grows, down when offset.y grows. + const dx = (rendered.offsetX - offset.x) * scale; + const dy = (offset.y - rendered.offsetY) * scale; + drawController.blitImage(offscreenCanvas, dx, dy); +} diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-version.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-version.ts new file mode 100644 index 00000000..5d64ca06 --- /dev/null +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-version.ts @@ -0,0 +1,13 @@ +/** + * Scene version counter — incremented whenever content that is baked into the + * cached static scene bitmap changes (entities, layers, selection, grid). + * Kept dependency-free so both state.ts and scene-cache.ts can import it + * without a cycle. + */ +let sceneVersion = 0; + +export const bumpSceneVersion = (): void => { + sceneVersion++; +}; + +export const getSceneVersion = (): number => sceneVersion; diff --git a/B07_wf4_DesignDetail/openwebcad/src/main.tsx b/B07_wf4_DesignDetail/openwebcad/src/main.tsx index a1884e83..f30c6057 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/main.tsx +++ b/B07_wf4_DesignDetail/openwebcad/src/main.tsx @@ -7,6 +7,7 @@ import App from './App.tsx'; import { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController'; import { draw } from './helpers/draw'; import { findClosestEntity } from './helpers/find-closest-entity'; +import { scenePerf } from './helpers/scene-cache'; import { getNewLayer } from './helpers/get-new-layer.ts'; import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points'; import { InputController } from './inputController/input-controller.ts'; @@ -40,6 +41,13 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLDivElement).render( ); +// Hover highlight throttle: the closest-entity scan is O(entities) so it runs +// at most every HOVER_THROTTLE_MS and only when the mouse actually moved. +const HOVER_THROTTLE_MS = 30; +let lastHoverCheckAt = 0; +let lastHoverMouseX = Number.NaN; +let lastHoverMouseY = Number.NaN; + function startDrawLoop( screenCanvasDrawController: ScreenCanvasDrawController, timestamp: DOMHighResTimeStamp @@ -48,6 +56,7 @@ function startDrawLoop( const elapsedTime = timestamp - lastDrawTimestamp; setLastDrawTimestamp(timestamp); + scenePerf.avgFrameMs = scenePerf.avgFrameMs * 0.9 + elapsedTime * 0.1; // biome-ignore lint/suspicious/noExplicitAny: const activeToolSnapshot: MachineSnapshot | undefined = @@ -70,13 +79,20 @@ function startDrawLoop( if (!screenCanvasDrawController) { throw new Error('getScreenCanvasDrawController() returned null'); } - const { distance, entity: closestEntity } = findClosestEntity( - screenCanvasDrawController.getWorldMouseLocation(), - getEntities() - ); + const mouseLocation = screenCanvasDrawController.getScreenMouseLocation(); + const mouseMoved = mouseLocation.x !== lastHoverMouseX || mouseLocation.y !== lastHoverMouseY; + if (mouseMoved && timestamp - lastHoverCheckAt >= HOVER_THROTTLE_MS) { + lastHoverCheckAt = timestamp; + lastHoverMouseX = mouseLocation.x; + lastHoverMouseY = mouseLocation.y; + const { distance, entity: closestEntity } = findClosestEntity( + screenCanvasDrawController.getWorldMouseLocation(), + getEntities() + ); - if (distance < HIGHLIGHT_ENTITY_DISTANCE) { - setHighlightedEntityIds([closestEntity.id]); + if (distance < HIGHLIGHT_ENTITY_DISTANCE) { + setHighlightedEntityIds([closestEntity.id]); + } } } diff --git a/B07_wf4_DesignDetail/openwebcad/src/state.ts b/B07_wf4_DesignDetail/openwebcad/src/state.ts index 28563c07..8aee5c52 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/state.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/state.ts @@ -12,6 +12,7 @@ import { } from './App.types'; import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController'; import type { Entity } from './entities/Entity'; +import { bumpSceneVersion } from './helpers/scene-version'; import { createStack, StateVariable, type UndoState } from './helpers/undo-stack'; import type { InputController } from './inputController/input-controller.ts'; // state variables @@ -214,6 +215,7 @@ export const getNotSelectedEntities = (): Entity[] => { }; export const isEntitySelected = (entity: Entity) => selectedEntityIds.includes(entity.id); export const isEntityHighlighted = (entity: Entity) => highlightedEntityIds.includes(entity.id); +export const getHighlightedEntityIds = () => highlightedEntityIds; export const getLayers = () => { return layers; }; @@ -279,6 +281,7 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => trackUndoState(StateVariable.entities, newEntities); } entities = newEntities; + bumpSceneVersion(); if (trackInUndoStack) { window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); } @@ -288,6 +291,7 @@ export const setHighlightedEntityIds = (newEntityIds: string[]) => { }; export const setSelectedEntityIds = (newEntityIds: string[]) => { selectedEntityIds = newEntityIds; + bumpSceneVersion(); // selection style (dashed) is baked into the scene cache window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; export const setShouldDrawCursor = (newValue: boolean) => { @@ -374,6 +378,7 @@ export const setActiveTextStyle = ( }; export const setLayers = (newLayers: Layer[], triggerReact = true) => { layers = newLayers; + bumpSceneVersion(); // layer visibility/lock affects what the scene cache shows if (triggerReact) { triggerReactUpdate(StateVariable.layers); @@ -398,6 +403,7 @@ export const setSnapEnabled = (enabled: boolean) => { }; export const setGridEnabled = (enabled: boolean) => { gridEnabled = enabled; + bumpSceneVersion(); window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; export const setDesignMeta = (newMeta: DesignMeta | null) => { @@ -462,6 +468,7 @@ function updateStates(undoState: UndoState) { switch (variable) { case StateVariable.entities: entities = value; + bumpSceneVersion(); break; } }