From eb5bdfebad4e74628d0af6333c5ef85dec784793 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 26 Jul 2026 20:54:20 +0900 Subject: [PATCH] auto: 2026-07-26 20:54 (ESD_LAPTOP) --- .../screenCanvas.drawController.ts | 114 ++++++++++++++ .../calculate-angle-guides-and-snap-points.ts | 14 +- .../openwebcad/src/helpers/draw-functions.ts | 4 +- .../openwebcad/src/helpers/scene-cache.ts | 26 +++- .../openwebcad/src/helpers/spatial-index.ts | 140 ++++++++++++++++++ B07_wf4_DesignDetail/openwebcad/src/main.tsx | 12 +- B07_wf4_DesignDetail/openwebcad/src/state.ts | 20 ++- 7 files changed, 316 insertions(+), 14 deletions(-) create mode 100644 B07_wf4_DesignDetail/openwebcad/src/helpers/spatial-index.ts diff --git a/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 2eec4a8b..a10e9010 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -27,12 +27,26 @@ import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController'; * * To convert between the 2 coordinate systems, you need the screenOffset and screenScale */ +// Batch-mode stroke decimation: skip chain segments shorter than this (screen px) +const BATCH_LOD_PX = 0.5; +// Batch-mode text smaller than this (screen px) is unreadable — skip drawing it +const BATCH_MIN_TEXT_PX = 2; + export class ScreenCanvasDrawController implements DrawController { private screenOffset: Point = new Point(0, 0); private screenScale = 1; private screenMouseLocation: Point; private canvasSize: Point = new Point(100, 100); + // Style-run batching (static scene rendering): consecutive stroke calls + // with the same style are collected into one Path2D and stroked once. + private batching = false; + private batchPath: Path2D | null = null; + private batchKey: string | null = null; + private batchStyle: { color: string; lineWidth: number; dash: number[] } | null = null; + private batchLastX = Number.NaN; + private batchLastY = Number.NaN; + constructor(private context: CanvasRenderingContext2D) { this.screenMouseLocation = new Point(this.canvasSize.x / 2, this.canvasSize.y / 2); this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down @@ -221,6 +235,18 @@ export class ScreenCanvasDrawController implements DrawController { lineWidth: number, dash: number[] = [] ) { + if (this.batching) { + const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth; + const effectiveDash = isSelected ? [5, 5] : dash; + const key = `${color}|${effectiveWidth}|${effectiveDash.join(',')}`; + if (key !== this.batchKey) { + this.flushBatch(); + this.batchKey = key; + this.batchStyle = { color, lineWidth: effectiveWidth, dash: effectiveDash }; + } + return; + } + this.context.strokeStyle = color; this.context.lineWidth = lineWidth; this.context.setLineDash(dash); @@ -234,6 +260,43 @@ export class ScreenCanvasDrawController implements DrawController { } } + /** + * Start style-run batching: consecutive stroke calls sharing a style are + * accumulated into a single Path2D and stroked once (with sub-pixel + * segment decimation). Used while rendering the static scene cache. + */ + public beginBatch() { + this.flushBatch(); + this.batching = true; + this.batchKey = null; + this.batchStyle = null; + } + + public endBatch() { + this.flushBatch(); + this.batching = false; + this.batchKey = null; + this.batchStyle = null; + } + + private flushBatch() { + if (this.batchPath && this.batchStyle) { + this.context.strokeStyle = this.batchStyle.color; + this.context.lineWidth = this.batchStyle.lineWidth; + this.context.setLineDash(this.batchStyle.dash); + // Round caps/joins replace the per-segment endpoint dots drawn in + // the unbatched path (see _drawRoundedEndpoint) + this.context.lineCap = 'round'; + this.context.lineJoin = 'round'; + this.context.stroke(this.batchPath); + this.context.lineCap = 'butt'; + this.context.lineJoin = 'miter'; + } + this.batchPath = null; + this.batchLastX = Number.NaN; + this.batchLastY = Number.NaN; + } + public setFillStyles(fillColor: string) { this.context.fillStyle = fillColor; } @@ -265,6 +328,7 @@ export class ScreenCanvasDrawController implements DrawController { if (this.canvasSize === null) return; if (!this.context) return; + if (this.batching) this.flushBatch(); this.context.fillStyle = CANVAS_BACKGROUND_COLOR; this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y); @@ -305,6 +369,33 @@ export class ScreenCanvasDrawController implements DrawController { * @param screenEndPoint */ public drawLineScreen(screenStartPoint: Point, screenEndPoint: Point): void { + if (this.batching) { + const startX = screenStartPoint.x; + const startY = this.canvasSize.y - screenStartPoint.y; + const endX = screenEndPoint.x; + const endY = this.canvasSize.y - screenEndPoint.y; + if (!this.batchPath) this.batchPath = new Path2D(); + // Chain break: start is not where the previous segment ended + const chainBroken = + Math.abs(startX - this.batchLastX) > BATCH_LOD_PX || + Math.abs(startY - this.batchLastY) > BATCH_LOD_PX; + if (chainBroken || Number.isNaN(this.batchLastX)) { + this.batchPath.moveTo(startX, startY); + this.batchLastX = startX; + this.batchLastY = startY; + } + const isTinyStep = + Math.abs(endX - this.batchLastX) < BATCH_LOD_PX && + Math.abs(endY - this.batchLastY) < BATCH_LOD_PX; + // Decimate sub-pixel steps inside a chain; isolated segments always draw + if (!isTinyStep || chainBroken) { + this.batchPath.lineTo(endX, endY); + this.batchLastX = endX; + this.batchLastY = endY; + } + return; + } + this.context.beginPath(); this.context.moveTo(screenStartPoint.x, this.canvasSize.y - screenStartPoint.y); this.context.lineTo(screenEndPoint.x, this.canvasSize.y - screenEndPoint.y); @@ -357,6 +448,22 @@ export class ScreenCanvasDrawController implements DrawController { endAngle: number, counterClockWise: boolean ) { + if (this.batching) { + if (screenRadius < BATCH_LOD_PX) return; // invisible at this zoom + if (!this.batchPath) this.batchPath = new Path2D(); + const centerX = screenCenterPoint.x; + const centerY = this.canvasSize.y - screenCenterPoint.y; + this.batchPath.moveTo( + centerX + screenRadius * Math.cos(startAngle), + centerY + screenRadius * Math.sin(startAngle) + ); + this.batchPath.arc(centerX, centerY, screenRadius, startAngle, endAngle, counterClockWise); + // Arc end becomes the new chain tail + this.batchLastX = centerX + screenRadius * Math.cos(endAngle); + this.batchLastY = centerY + screenRadius * Math.sin(endAngle); + return; + } + this.context.beginPath(); this.context.arc( screenCenterPoint.x, @@ -434,6 +541,10 @@ export class ScreenCanvasDrawController implements DrawController { ...DEFAULT_TEXT_OPTIONS, ...options, }; + if (this.batching) { + if (opts.fontSize < BATCH_MIN_TEXT_PX) return; // unreadable at this zoom + this.flushBatch(); // keep draw order: strokes so far go under this text + } this.context.save(); this.context.translate(basePoint.x, this.canvasSize.y - basePoint.y); const angle = getAngleWithXAxis( @@ -466,6 +577,7 @@ export class ScreenCanvasDrawController implements DrawController { height: number, angle: number ): void { + if (this.batching) this.flushBatch(); const [screenBasePoint, screenDimensions] = this.worldsToTargets([ new Point(xMin, yMin), new Point(width, height), @@ -516,6 +628,7 @@ export class ScreenCanvasDrawController implements DrawController { * @param color */ public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) { + if (this.batching) this.flushBatch(); // TODO see if we need to replace this with a call to fillPolygon this.context.fillStyle = color; this.context.fillRect(xMin, this.canvasSize.y - yMin, width, height); @@ -526,6 +639,7 @@ export class ScreenCanvasDrawController implements DrawController { * @param points */ public fillPolygon(...points: Point[]) { + if (this.batching) this.flushBatch(); const screenPoints = points.map(this.worldToTarget.bind(this)); this.context.beginPath(); screenPoints.forEach((screenPoint, index) => { diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts index d4a79d2a..61118822 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts @@ -1,8 +1,8 @@ import { getAngleGuideOriginPoint, getAngleStep, - getEntities, getHoveredSnapPoints, + getLayerById, getScreenCanvasDrawController, getShouldDrawHelpers, setAngleGuideEntities, @@ -11,6 +11,7 @@ import { } from '../state.ts'; import { HOVERED_SNAP_POINT_TIME, SNAP_POINT_DISTANCE } from '../App.consts.ts'; import { getDrawHelpers } from './get-draw-guides.ts'; +import { queryEntitiesNearPoint } from './spatial-index.ts'; import { compact } from 'es-toolkit'; /** @@ -19,9 +20,16 @@ import { compact } from 'es-toolkit'; export function calculateAngleGuidesAndSnapPoints() { const angleStep = getAngleStep(); const screenCanvasDrawController = getScreenCanvasDrawController(); - const entities = getEntities(); const screenScale = screenCanvasDrawController.getScreenScale(); const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation(); + // 스냅 후보: 공간 인덱스로 마우스 주변만 조회 (전 엔티티 O(n²) 교차 계산 제거), + // 잠금 레이어(b07-frame 등 참조용)는 스냅 대상에서 제외한다. + const maxSnapDistance = SNAP_POINT_DISTANCE / screenScale; + const entities = queryEntitiesNearPoint( + worldMouseLocation.x, + worldMouseLocation.y, + maxSnapDistance * 2, + ).filter(entity => !getLayerById(entity.layerId)?.isLocked); const hoveredSnapPoints = getHoveredSnapPoints(); const eligibleHoveredSnapPoints = hoveredSnapPoints.filter( @@ -39,7 +47,7 @@ export function calculateAngleGuidesAndSnapPoints() { compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]), worldMouseLocation, angleStep, - SNAP_POINT_DISTANCE / screenScale, + maxSnapDistance, ); setAngleGuideEntities(angleGuides); setSnapPoint(entitySnapPoint); diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/draw-functions.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/draw-functions.ts index 7912eea9..71ff2abe 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/helpers/draw-functions.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/draw-functions.ts @@ -4,12 +4,12 @@ import {type SnapPoint, SnapPointType} from '../App.types'; import type {DrawController} from '../drawControllers/DrawController'; import type {ScreenCanvasDrawController} from '../drawControllers/screenCanvas.drawController'; import type {Entity} from '../entities/Entity'; -import {getLayers, isEntityHighlighted, isEntitySelected} from '../state'; +import {getLayerById, isEntityHighlighted, isEntitySelected} from '../state'; import {toast} from 'react-toastify'; export function drawEntities(drawController: DrawController, entities: Entity[]) { for (const entity of entities) { - const layer = getLayers().find((layer) => layer.id === entity.layerId); + const layer = getLayerById(entity.layerId); if (!layer) { toast.error(`Failed to find layer for entity: ${entity?.id}`); console.error('Failed to find layer for entity: ', entity); diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts index f96d2d9e..b2eb832b 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts @@ -1,12 +1,12 @@ import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; import { - getEntities, getGridEnabled, getHighlightedEntityIds, setHighlightedEntityIds, } from '../state'; import { drawEntities } from './draw-functions'; import { getSceneVersion } from './scene-version'; +import { queryEntitiesInBox } from './spatial-index'; /** * Static scene cache: all entities are rendered once into an offscreen canvas. @@ -46,6 +46,25 @@ export function invalidateSceneCache(): void { rendered = null; } +/** + * Viewport culling: only entities whose bbox intersects the current view + * (expanded by one viewport on each side, so short pans stay covered by the + * blit before the settle-rebuild) are rendered into the scene cache. + */ +function sceneEntitiesForViewport(drawController: ScreenCanvasDrawController) { + const size = drawController.getCanvasSize(); + const scale = drawController.getScreenScale(); + const offset = drawController.getScreenOffset(); + const viewWidth = size.x / scale; + const viewHeight = size.y / scale; + return queryEntitiesInBox( + offset.x - viewWidth, + offset.y - viewHeight, + offset.x + 2 * viewWidth, + offset.y + 2 * viewHeight + ); +} + function rebuildScene(drawController: ScreenCanvasDrawController): void { const size = drawController.getCanvasSize(); if (!offscreenCanvas) { @@ -64,7 +83,10 @@ function rebuildScene(drawController: ScreenCanvasDrawController): void { if (savedHighlight.length) setHighlightedEntityIds([]); drawController.withContext(offscreenContext, () => { drawController.clear(); - drawEntities(drawController, getEntities()); + // Style-run batching + sub-pixel decimation: one stroke per style run + drawController.beginBatch(); + drawEntities(drawController, sceneEntitiesForViewport(drawController)); + drawController.endBatch(); }); if (savedHighlight.length) setHighlightedEntityIds(savedHighlight); diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/spatial-index.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/spatial-index.ts new file mode 100644 index 00000000..2c9c8453 --- /dev/null +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/spatial-index.ts @@ -0,0 +1,140 @@ +import type { Entity } from '../entities/Entity'; +import { getEntities } from '../state'; +import { getSceneVersion } from './scene-version'; + +/** + * Uniform-grid spatial index over top-level entity bounding boxes. + * Rebuilt lazily whenever the scene version changes (entity edits bump it). + * Queries return entities in original array order so z-order is preserved. + */ +const GRID_CELLS_PER_AXIS = 64; + +interface IndexedEntity { + entity: Entity; + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +let indexVersion = -1; +let indexedEntities: IndexedEntity[] = []; +let unindexedEntities: Entity[] = []; // bbox unavailable — always included in results +let cells: Map = new Map(); +let cellSize = 1; +let gridMinX = 0; +let gridMinY = 0; +let gridCols = 1; + +function cellRange(min: number, max: number, gridMin: number): [number, number] { + return [Math.floor((min - gridMin) / cellSize), Math.floor((max - gridMin) / cellSize)]; +} + +function ensureIndex(): void { + const version = getSceneVersion(); + if (version === indexVersion) return; + + indexedEntities = []; + unindexedEntities = []; + cells = new Map(); + + const entities = getEntities(); + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + for (const entity of entities) { + let box: { xmin: number; ymin: number; xmax: number; ymax: number } | null = null; + try { + box = entity.getBoundingBox(); + } catch { + box = null; + } + if ( + !box || + !Number.isFinite(box.xmin) || + !Number.isFinite(box.ymin) || + !Number.isFinite(box.xmax) || + !Number.isFinite(box.ymax) + ) { + unindexedEntities.push(entity); + continue; + } + indexedEntities.push({ + entity, + minX: box.xmin, + minY: box.ymin, + maxX: box.xmax, + maxY: box.ymax, + }); + if (box.xmin < minX) minX = box.xmin; + if (box.ymin < minY) minY = box.ymin; + if (box.xmax > maxX) maxX = box.xmax; + if (box.ymax > maxY) maxY = box.ymax; + } + + if (indexedEntities.length) { + const extent = Math.max(maxX - minX, maxY - minY, 1e-9); + cellSize = extent / GRID_CELLS_PER_AXIS; + gridMinX = minX; + gridMinY = minY; + gridCols = GRID_CELLS_PER_AXIS + 2; + + indexedEntities.forEach((item, index) => { + const [cx0, cx1] = cellRange(item.minX, item.maxX, gridMinX); + const [cy0, cy1] = cellRange(item.minY, item.maxY, gridMinY); + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + const key = cy * gridCols + cx; + const bucket = cells.get(key); + if (bucket) bucket.push(index); + else cells.set(key, [index]); + } + } + }); + } + + indexVersion = version; +} + +/** 뷰포트/사각 영역과 bbox가 겹치는 엔티티 (원본 배열 순서 유지). */ +export function queryEntitiesInBox( + minX: number, + minY: number, + maxX: number, + maxY: number +): Entity[] { + ensureIndex(); + if (!indexedEntities.length) return [...unindexedEntities]; + + const seen = new Set(); + const [cx0, cx1] = cellRange(minX, maxX, gridMinX); + const [cy0, cy1] = cellRange(minY, maxY, gridMinY); + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + const bucket = cells.get(cy * gridCols + cx); + if (!bucket) continue; + for (const index of bucket) seen.add(index); + } + } + + const result: Entity[] = []; + let unindexedCursor = 0; + for (let index = 0; index < indexedEntities.length; index++) { + if (!seen.has(index)) continue; + const item = indexedEntities[index]; + if (item.maxX < minX || item.minX > maxX || item.maxY < minY || item.minY > maxY) continue; + result.push(item.entity); + } + // bbox 불명 엔티티는 항상 포함 (뒤에 붙여도 소수라 시각 영향 없음) + for (; unindexedCursor < unindexedEntities.length; unindexedCursor++) { + result.push(unindexedEntities[unindexedCursor]); + } + return result; +} + +/** 점 주변 반경 후보 엔티티 (스냅·호버용). */ +export function queryEntitiesNearPoint(x: number, y: number, radius: number): Entity[] { + return queryEntitiesInBox(x - radius, y - radius, x + radius, y + radius); +} diff --git a/B07_wf4_DesignDetail/openwebcad/src/main.tsx b/B07_wf4_DesignDetail/openwebcad/src/main.tsx index f30c6057..6ffec00a 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/main.tsx +++ b/B07_wf4_DesignDetail/openwebcad/src/main.tsx @@ -8,6 +8,7 @@ import { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawC import { draw } from './helpers/draw'; import { findClosestEntity } from './helpers/find-closest-entity'; import { scenePerf } from './helpers/scene-cache'; +import { queryEntitiesNearPoint } from './helpers/spatial-index'; import { getNewLayer } from './helpers/get-new-layer.ts'; import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points'; import { InputController } from './inputController/input-controller.ts'; @@ -15,7 +16,6 @@ import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.t import { getActiveToolActor, getCanvas, - getEntities, getHoveredSnapPoints, getLastDrawTimestamp, getScreenCanvasDrawController, @@ -85,9 +85,15 @@ function startDrawLoop( lastHoverCheckAt = timestamp; lastHoverMouseX = mouseLocation.x; lastHoverMouseY = mouseLocation.y; + const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation(); const { distance, entity: closestEntity } = findClosestEntity( - screenCanvasDrawController.getWorldMouseLocation(), - getEntities() + worldMouseLocation, + // 공간 인덱스로 후보를 좁혀 O(전체) 스캔 제거 + queryEntitiesNearPoint( + worldMouseLocation.x, + worldMouseLocation.y, + HIGHLIGHT_ENTITY_DISTANCE + ) ); if (distance < HIGHLIGHT_ENTITY_DISTANCE) { diff --git a/B07_wf4_DesignDetail/openwebcad/src/state.ts b/B07_wf4_DesignDetail/openwebcad/src/state.ts index 8aee5c52..3c3049d0 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/state.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/state.ts @@ -42,11 +42,13 @@ let entities: Entity[] = []; * Entities that are highlighted: when the mouse is close to an entity */ let highlightedEntityIds: string[] = []; +let highlightedEntityIdSet: Set = new Set(); /** * Entities that are selected by the user by clicking on them with the select tool or by selecting them with a selection rectangle */ let selectedEntityIds: string[] = []; +let selectedEntityIdSet: Set = new Set(); /** * Whether to draw the cursor or not @@ -163,6 +165,12 @@ let layers: Layer[] = [ */ let activeLayerId: string = layers[0].id; +/** + * layerId → Layer lookup, kept in sync with `layers` (drawEntities runs this + * lookup once per entity per frame — a linear find() was a hot spot) + */ +let layersById: Map = new Map(layers.map((layer) => [layer.id, layer])); + let snapEnabled = true; let gridEnabled = false; @@ -208,17 +216,18 @@ export const getInputController = (): InputController => { }; export const getSelectedEntities = (): Entity[] => { - return entities.filter((e) => selectedEntityIds.includes(e.id)); + return entities.filter((e) => selectedEntityIdSet.has(e.id)); }; export const getNotSelectedEntities = (): Entity[] => { - return entities.filter((e) => !selectedEntityIds.includes(e.id)); + return entities.filter((e) => !selectedEntityIdSet.has(e.id)); }; -export const isEntitySelected = (entity: Entity) => selectedEntityIds.includes(entity.id); -export const isEntityHighlighted = (entity: Entity) => highlightedEntityIds.includes(entity.id); +export const isEntitySelected = (entity: Entity) => selectedEntityIdSet.has(entity.id); +export const isEntityHighlighted = (entity: Entity) => highlightedEntityIdSet.has(entity.id); export const getHighlightedEntityIds = () => highlightedEntityIds; export const getLayers = () => { return layers; }; +export const getLayerById = (layerId: string): Layer | undefined => layersById.get(layerId); export const getActiveLayerId = (): string => { return activeLayerId; }; @@ -288,9 +297,11 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => }; export const setHighlightedEntityIds = (newEntityIds: string[]) => { highlightedEntityIds = newEntityIds; + highlightedEntityIdSet = new Set(newEntityIds); }; export const setSelectedEntityIds = (newEntityIds: string[]) => { selectedEntityIds = newEntityIds; + selectedEntityIdSet = new Set(newEntityIds); bumpSceneVersion(); // selection style (dashed) is baked into the scene cache window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; @@ -378,6 +389,7 @@ export const setActiveTextStyle = ( }; export const setLayers = (newLayers: Layer[], triggerReact = true) => { layers = newLayers; + layersById = new Map(newLayers.map((layer) => [layer.id, layer])); bumpSceneVersion(); // layer visibility/lock affects what the scene cache shows if (triggerReact) {