diff --git a/packages/example/index.html b/packages/example/index.html index 6c69a00..53b0bb4 100644 --- a/packages/example/index.html +++ b/packages/example/index.html @@ -442,6 +442,10 @@

Rendering

+
+ + +
@@ -564,7 +568,7 @@

Actions

Accepts .shx / .ttf / .otf / .woff. Cached entries show [cached] in the lists.

-

Lazy Font Loading skips preload and redraws on fontLoaded. Use Release Fonts, then Render to compare.

+

Lazy Font Loading skips preload. Unchecked Await Fonts redraws on fontLoaded; checked waits for fonts before the first draw. Use Release Fonts, then Render to compare.

diff --git a/packages/example/src/exampleFontManager.ts b/packages/example/src/exampleFontManager.ts index 2100a0d..beed0a2 100644 --- a/packages/example/src/exampleFontManager.ts +++ b/packages/example/src/exampleFontManager.ts @@ -62,6 +62,14 @@ export class ExampleFontManager { return FontManager.instance.lazyFontLoading } + /** + * @returns Whether {@link FontManager.awaitFontsBeforeDraw} is currently + * enabled. + */ + isAwaitFontsBeforeDraw(): boolean { + return FontManager.instance.awaitFontsBeforeDraw + } + /** * Mirrors the UI checkbox onto {@link FontManager.lazyFontLoading} and any * existing worker pool. @@ -72,6 +80,16 @@ export class ExampleFontManager { await this.unifiedRenderer.setLazyFontLoading(enabled) } + /** + * Mirrors the UI checkbox onto {@link FontManager.awaitFontsBeforeDraw} and + * any existing worker pool. + * + * @param enabled - When true with lazy loading, draw waits for referenced fonts. + */ + async setAwaitFontsBeforeDraw(enabled: boolean): Promise { + await this.unifiedRenderer.setAwaitFontsBeforeDraw(enabled) + } + /** * Fonts that non-lazy mode should preload for the active preset and selects. */ diff --git a/packages/example/src/main.ts b/packages/example/src/main.ts index 809cb78..595cd79 100644 --- a/packages/example/src/main.ts +++ b/packages/example/src/main.ts @@ -122,6 +122,11 @@ class MTextRendererExample { private readonly defaultFontsPresetSelect: HTMLSelectElement /** `#lazy-font-loading` — toggles {@link FontManager.lazyFontLoading}. */ private readonly lazyFontLoadingCheckbox: HTMLInputElement + /** + * `#await-fonts-before-draw` — toggles {@link FontManager.awaitFontsBeforeDraw} + * (only meaningful when lazy font loading is on). + */ + private readonly awaitFontsBeforeDrawCheckbox: HTMLInputElement /** DXF layer name passed in {@link getColorSettings} for ByLayer color resolution. */ private readonly defaultLayerName = '0' @@ -211,6 +216,9 @@ class MTextRendererExample { this.lazyFontLoadingCheckbox = document.getElementById( 'lazy-font-loading' ) as HTMLInputElement + this.awaitFontsBeforeDrawCheckbox = document.getElementById( + 'await-fonts-before-draw' + ) as HTMLInputElement this.fontManager = new ExampleFontManager( this.unifiedRenderer, this.statusDiv, @@ -224,6 +232,11 @@ class MTextRendererExample { void this.fontManager.setLazyFontLoading( this.lazyFontLoadingCheckbox.checked ) + this.syncAwaitFontsBeforeDrawCheckbox() + void this.fontManager.setAwaitFontsBeforeDraw( + this.lazyFontLoadingCheckbox.checked && + this.awaitFontsBeforeDrawCheckbox.checked + ) FontManager.instance.events.fontLoaded.addEventListener(payload => { this.onLazyFontLoaded(payload.fontName) }) @@ -411,6 +424,12 @@ class MTextRendererExample { await this.applyLazyFontLoadingMode(this.lazyFontLoadingCheckbox.checked) }) + this.awaitFontsBeforeDrawCheckbox.addEventListener('change', async () => { + await this.applyAwaitFontsBeforeDrawMode( + this.awaitFontsBeforeDrawCheckbox.checked + ) + }) + document.querySelectorAll('.example-btn').forEach(button => { button.addEventListener('click', async () => { const exampleType = (button as HTMLElement).dataset @@ -523,12 +542,18 @@ class MTextRendererExample { */ private async applyLazyFontLoadingMode(enabled: boolean): Promise { await this.fontManager.setLazyFontLoading(enabled) + this.syncAwaitFontsBeforeDrawCheckbox() + await this.fontManager.setAwaitFontsBeforeDraw( + enabled && this.awaitFontsBeforeDrawCheckbox.checked + ) if (enabled) { FontManager.instance.release() this.unifiedRenderer.terminateWorkers() await this.fontManager.applyDefaultFontsPreset() - this.statusDiv.textContent = - 'Lazy font loading ON — fonts released; next render loads on demand' + const awaitFonts = this.awaitFontsBeforeDrawCheckbox.checked + this.statusDiv.textContent = awaitFonts + ? 'Lazy font loading ON + await — next render waits for fonts, then draws once' + : 'Lazy font loading ON — fonts released; next render loads on demand' this.statusDiv.style.color = '#0f0' } else { await this.fontManager.applyDefaultFontsPreset() @@ -540,6 +565,32 @@ class MTextRendererExample { await this.renderCurrentContent() } + /** + * Enables/disables the Await Fonts checkbox to match Lazy Font Loading, and + * keeps {@link FontManager.awaitFontsBeforeDraw} in sync. + */ + private syncAwaitFontsBeforeDrawCheckbox(): void { + const lazy = this.lazyFontLoadingCheckbox.checked + this.awaitFontsBeforeDrawCheckbox.disabled = !lazy + } + + /** + * Applies the Await Fonts Before Draw checkbox when lazy loading is enabled. + */ + private async applyAwaitFontsBeforeDrawMode(enabled: boolean): Promise { + if (!this.lazyFontLoadingCheckbox.checked) { + this.syncAwaitFontsBeforeDrawCheckbox() + return + } + await this.fontManager.setAwaitFontsBeforeDraw(enabled) + this.statusDiv.textContent = enabled + ? 'Await fonts ON — render waits for download/load, then draws once' + : 'Await fonts OFF — render draws with fallbacks, then redraws on fontLoaded' + this.statusDiv.style.color = '#0f0' + this.beginUserRender() + await this.renderCurrentContent() + } + /** * Handles {@link FontManager.events.fontLoaded} by scheduling a debounced redraw * so glyph fallbacks are replaced once background loads finish. @@ -548,6 +599,10 @@ class MTextRendererExample { if (!this.fontManager.isLazyFontLoading()) { return } + // Draw already waited for referenced fonts — no progressive redraw needed. + if (this.fontManager.isAwaitFontsBeforeDraw()) { + return + } if (!this.lazyFontsLoadedSinceRender.includes(fontName)) { this.lazyFontsLoadedSinceRender.push(fontName) } @@ -1042,8 +1097,12 @@ class MTextRendererExample { /** Appends lazy/non-lazy and thread mode details to a render status line. */ private formatRenderStatus(base: string, lazy: boolean): string { - const mode = lazy ? 'lazy' : 'non-lazy' + const awaitFonts = this.fontManager.isAwaitFontsBeforeDraw() + const mode = lazy ? (awaitFonts ? 'lazy+await' : 'lazy') : 'non-lazy' const thread = this.renderModeSelect.value + if (lazy && awaitFonts) { + return `${base} (${mode}, ${thread}) · waited for fonts before draw` + } if (lazy && this.lazyFontsLoadedSinceRender.length > 0) { return `${base} (${mode}, ${thread}) · loaded [${this.lazyFontsLoadedSinceRender.join(', ')}]` } diff --git a/packages/mtext-renderer/package.json b/packages/mtext-renderer/package.json index 29a5f89..042d17e 100644 --- a/packages/mtext-renderer/package.json +++ b/packages/mtext-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@mlightcad/mtext-renderer", - "version": "0.12.2", + "version": "0.12.3", "description": "AutoCAD MText renderer based on Three.js", "license": "MIT", "author": "MLight Lee ", diff --git a/packages/mtext-renderer/src/font/fontManager.ts b/packages/mtext-renderer/src/font/fontManager.ts index 6d0c0ea..ab06016 100644 --- a/packages/mtext-renderer/src/font/fontManager.ts +++ b/packages/mtext-renderer/src/font/fontManager.ts @@ -76,9 +76,19 @@ export class FontManager { /** * When true (default), missing fonts are fetched/parsed in the background * via {@link requestFont} instead of requiring an open-time preload. - * Drawing continues with temporary fallbacks until {@link events.fontLoaded}. + * Drawing continues with temporary fallbacks until {@link events.fontLoaded} + * unless {@link awaitFontsBeforeDraw} (or a per-draw override) waits first. */ public lazyFontLoading = true + /** + * When true, {@link MText.asyncDraw} / {@link Shape.asyncDraw} wait for fonts + * referenced by the content and style to finish loading before building + * geometry. Useful with {@link lazyFontLoading} when callers prefer a single + * draw pass instead of redrawing on {@link events.fontLoaded}. + * + * Has no effect when {@link lazyFontLoading} is false (draw already awaits). + */ + public awaitFontsBeforeDraw = false /** * Default fonts to use when a requested font is not found or lacks a glyph. * Insertion order is preserved; earlier entries are tried first. @@ -360,12 +370,14 @@ export class FontManager { } /** - * Fire-and-forget {@link requestFont} for each name (deduped per name). + * Requests each font name via {@link requestFont} (deduped per name). + * Callers may ignore the returned promise for fire-and-forget loads, or + * await it when they need fonts before drawing. */ - requestFonts(fontNames: readonly string[]): void { - for (const name of fontNames) { - void this.requestFont(name) - } + requestFonts(fontNames: readonly string[]): Promise { + return Promise.all(fontNames.map(name => this.requestFont(name))).then( + results => results.flat() + ) } private normalizeFontName(fontName: string): string { diff --git a/packages/mtext-renderer/src/renderer/mtext.ts b/packages/mtext-renderer/src/renderer/mtext.ts index 4078708..641cffe 100644 --- a/packages/mtext-renderer/src/renderer/mtext.ts +++ b/packages/mtext-renderer/src/renderer/mtext.ts @@ -35,6 +35,21 @@ const tempPoint2 = /*@__PURE__*/ new THREE.Vector3() const tempPoint3 = /*@__PURE__*/ new THREE.Vector3() const AxisX = /*@__PURE__*/ new THREE.Vector3(1, 0, 0) +/** + * Options for {@link MText.asyncDraw} / {@link Shape.asyncDraw}. + */ +export interface MTextDrawOptions { + /** + * Wait for fonts referenced by the content/style to finish loading before + * building geometry. + * + * Defaults to `true` when {@link FontManager.lazyFontLoading} is false, or + * when {@link FontManager.awaitFontsBeforeDraw} is true. Otherwise fonts are + * scheduled in the background and the first draw may use fallbacks. + */ + awaitFonts?: boolean +} + /** * Axis-aligned rectangle (plus baseline) describing the **logical MText frame** * used to compute attachment-point offsets in {@link MText.loadMText}. @@ -182,13 +197,15 @@ export class MText extends THREE.Object3D { } /** - * Draw the MText object. Schedules required fonts for background load and - * builds the object graph immediately with current fallbacks. + * Draw the MText object. * - * Does not await downloads — callers that need glyphs from newly loaded fonts - * should redraw after {@link FontManager.events.fontLoaded}. + * With {@link FontManager.lazyFontLoading} and without awaiting fonts, this + * schedules downloads in the background and builds geometry immediately with + * current fallbacks — redraw after {@link FontManager.events.fontLoaded} if + * you need the final faces. Pass `{ awaitFonts: true }` or set + * {@link FontManager.awaitFontsBeforeDraw} to wait for referenced fonts first. */ - async asyncDraw() { + async asyncDraw(options?: MTextDrawOptions) { // Determine fonts used in the mtext string (without extensions) const fonts = Array.from(MText.getFonts(this._mtextData.text || '', true)) @@ -208,8 +225,17 @@ export class MText extends THREE.Object3D { } } if (fonts.length > 0) { + const awaitFonts = + options?.awaitFonts ?? + (!this._fontManager.lazyFontLoading || + this._fontManager.awaitFontsBeforeDraw) + if (this._fontManager.lazyFontLoading) { - this._fontManager.requestFonts(fonts) + if (awaitFonts) { + await this._fontManager.requestFonts(fonts) + } else { + void this._fontManager.requestFonts(fonts) + } } else { await this._fontManager.loadFontsByNames(fonts) } diff --git a/packages/mtext-renderer/src/renderer/shape.ts b/packages/mtext-renderer/src/renderer/shape.ts index 60c08e3..bfc56c5 100644 --- a/packages/mtext-renderer/src/renderer/shape.ts +++ b/packages/mtext-renderer/src/renderer/shape.ts @@ -1,7 +1,7 @@ import * as THREE from 'three' import { FontManager } from '../font' -import { MText } from './mtext' +import { MText, MTextDrawOptions } from './mtext' import { StyleManager } from './styleManager' import { ColorSettings, @@ -62,7 +62,7 @@ export class Shape extends THREE.Object3D { return { lines: [], chars: [] } } - async asyncDraw() { + async asyncDraw(options?: MTextDrawOptions) { const fonts: string[] = [] if (!this._fontsInStyleLoaded) { for (const key of ['font', 'bigFont', 'extendedFont'] as const) { @@ -71,8 +71,17 @@ export class Shape extends THREE.Object3D { } } if (fonts.length > 0) { + const awaitFonts = + options?.awaitFonts ?? + (!this._fontManager.lazyFontLoading || + this._fontManager.awaitFontsBeforeDraw) + if (this._fontManager.lazyFontLoading) { - this._fontManager.requestFonts(fonts) + if (awaitFonts) { + await this._fontManager.requestFonts(fonts) + } else { + void this._fontManager.requestFonts(fonts) + } } else { await this._fontManager.loadFontsByNames(fonts) } diff --git a/packages/mtext-renderer/src/worker/mainThreadRenderer.ts b/packages/mtext-renderer/src/worker/mainThreadRenderer.ts index 5e789c7..5842049 100644 --- a/packages/mtext-renderer/src/worker/mainThreadRenderer.ts +++ b/packages/mtext-renderer/src/worker/mainThreadRenderer.ts @@ -52,7 +52,9 @@ export class MainThreadRenderer implements MTextBaseRenderer { /** * Render MText directly in the main thread asynchronously. Fonts referenced by * the text/style are scheduled via {@link FontManager.requestFonts} when - * {@link FontManager.lazyFontLoading} is enabled; otherwise they are awaited. + * {@link FontManager.lazyFontLoading} is enabled (unless + * {@link FontManager.awaitFontsBeforeDraw} waits for them first); otherwise + * they are awaited. */ async asyncRenderMText( mtextContent: MTextData, diff --git a/packages/mtext-renderer/src/worker/mtextWorker.ts b/packages/mtext-renderer/src/worker/mtextWorker.ts index 149b6df..7b2eddf 100644 --- a/packages/mtext-renderer/src/worker/mtextWorker.ts +++ b/packages/mtext-renderer/src/worker/mtextWorker.ts @@ -22,6 +22,7 @@ interface WorkerMessage { | 'loadFonts' | 'setDefaultFonts' | 'setLazyFontLoading' + | 'setAwaitFontsBeforeDraw' | 'setFontUrl' | 'getAvailableFonts' | 'getMemoryStats' @@ -43,6 +44,7 @@ interface WorkerResponse { | 'loadFonts' | 'setDefaultFonts' | 'setLazyFontLoading' + | 'setAwaitFontsBeforeDraw' | 'setFontUrl' | 'getAvailableFonts' | 'getMemoryStats' @@ -162,6 +164,21 @@ self.addEventListener('message', async (event: MessageEvent) => { break } + case 'setAwaitFontsBeforeDraw': { + if (!data) { + throw new Error('Missing data for setAwaitFontsBeforeDraw message') + } + const { enabled } = data as { enabled: boolean } + fontManager.awaitFontsBeforeDraw = enabled + self.postMessage({ + type: 'setAwaitFontsBeforeDraw', + id, + success: true, + data: { enabled: fontManager.awaitFontsBeforeDraw } + } as WorkerResponse) + break + } + case 'setFontUrl': { if (!data) throw new Error('Missing data for setFontUrl message') const { url } = data as { url: string } diff --git a/packages/mtext-renderer/src/worker/unifiedRenderer.ts b/packages/mtext-renderer/src/worker/unifiedRenderer.ts index a716574..1fe1f5a 100644 --- a/packages/mtext-renderer/src/worker/unifiedRenderer.ts +++ b/packages/mtext-renderer/src/worker/unifiedRenderer.ts @@ -30,6 +30,8 @@ export class UnifiedRenderer { private webWorkerConfigured = false /** Last lazyFontLoading value pushed to the worker pool, if any. */ private workerLazyFontLoading: boolean | null = null + /** Last awaitFontsBeforeDraw value pushed to the worker pool, if any. */ + private workerAwaitFontsBeforeDraw: boolean | null = null /** * Constructor * @@ -73,6 +75,11 @@ export class UnifiedRenderer { await renderer.setLazyFontLoading(lazy) this.workerLazyFontLoading = lazy } + const awaitFonts = FontManager.instance.awaitFontsBeforeDraw + if (this.workerAwaitFontsBeforeDraw !== awaitFonts) { + await renderer.setAwaitFontsBeforeDraw(awaitFonts) + this.workerAwaitFontsBeforeDraw = awaitFonts + } return renderer } @@ -217,6 +224,18 @@ export class UnifiedRenderer { } } + /** + * Mirrors {@link FontManager.awaitFontsBeforeDraw} onto the main thread and + * any existing worker pool. + */ + async setAwaitFontsBeforeDraw(enabled: boolean): Promise { + FontManager.instance.awaitFontsBeforeDraw = enabled + if (this.webWorkerRenderer) { + await this.webWorkerRenderer.setAwaitFontsBeforeDraw(enabled) + this.workerAwaitFontsBeforeDraw = enabled + } + } + /** * Returns font names for a predefined default-font preset. */ diff --git a/packages/mtext-renderer/src/worker/webWorkerRenderer.ts b/packages/mtext-renderer/src/worker/webWorkerRenderer.ts index 72cd3a5..d33b7e8 100644 --- a/packages/mtext-renderer/src/worker/webWorkerRenderer.ts +++ b/packages/mtext-renderer/src/worker/webWorkerRenderer.ts @@ -104,11 +104,19 @@ type SetLazyFontLoadingMessage = WorkerMessageBase< } > +type SetAwaitFontsBeforeDrawMessage = WorkerMessageBase< + 'setAwaitFontsBeforeDraw', + { + enabled: boolean + } +> + type WorkerMessageTyped = | RenderMessage | LoadFontsMessage | SetDefaultFontsMessage | SetLazyFontLoadingMessage + | SetAwaitFontsBeforeDrawMessage | SetFontUrlMessage | GetAvailableFontsMessage | GetMemoryStatsMessage @@ -154,6 +162,13 @@ type SetLazyFontLoadingResponse = WorkerResponseBase< } > +type SetAwaitFontsBeforeDrawResponse = WorkerResponseBase< + 'setAwaitFontsBeforeDraw', + { + enabled: boolean + } +> + /** Push notification from a worker when a font finishes lazy-loading. */ type FontLoadedNotification = WorkerResponseBase< 'fontLoaded', @@ -167,6 +182,7 @@ type WorkerResponseTyped = | LoadFontsResponse | SetDefaultFontsResponse | SetLazyFontLoadingResponse + | SetAwaitFontsBeforeDrawResponse | SetFontUrlResponse | GetAvailableFontsResponse | GetMemoryStatsResponse @@ -587,6 +603,20 @@ export class WebWorkerRenderer implements MTextBaseRenderer { }) } + /** + * Mirrors {@link FontManager.awaitFontsBeforeDraw} into every worker isolate. + */ + async setAwaitFontsBeforeDraw(enabled: boolean): Promise { + FontManager.instance.awaitFontsBeforeDraw = enabled + await this.sendMessageToAllWorkers< + SetAwaitFontsBeforeDrawMessage, + SetAwaitFontsBeforeDrawResponse + >({ + type: 'setAwaitFontsBeforeDraw', + data: { enabled } + }) + } + /** * Render MText in one worker and return serialized data asynchronously. */ diff --git a/packages/mtext-renderer/test/worker/render-remote-font-loading.test.ts b/packages/mtext-renderer/test/worker/render-remote-font-loading.test.ts index 6671da7..524ed46 100644 --- a/packages/mtext-renderer/test/worker/render-remote-font-loading.test.ts +++ b/packages/mtext-renderer/test/worker/render-remote-font-loading.test.ts @@ -44,7 +44,10 @@ class MockWorker { } as MessageEvent) return } - if (type === 'setLazyFontLoading') { + if ( + type === 'setLazyFontLoading' || + type === 'setAwaitFontsBeforeDraw' + ) { this.onmessage?.({ data: { id, @@ -128,6 +131,7 @@ describe('render remote font loading', () => { beforeEach(() => { FontManager.instance.release() FontManager.instance.lazyFontLoading = true + FontManager.instance.awaitFontsBeforeDraw = false FontManager.instance.defaultFonts = new Set(['simkai']) FontManager.instance.symbolFonts = new Set(['amgdt']) loadFontsByNames = vi @@ -139,6 +143,7 @@ describe('render remote font loading', () => { afterEach(() => { loadFontsByNames.mockRestore() FontManager.instance.lazyFontLoading = true + FontManager.instance.awaitFontsBeforeDraw = false vi.unstubAllGlobals() vi.stubGlobal('Worker', MockWorker) }) @@ -200,6 +205,78 @@ describe('render remote font loading', () => { requestFonts.mockRestore() }) + it('MText.asyncDraw awaits requestFonts when awaitFontsBeforeDraw is true', async () => { + FontManager.instance.awaitFontsBeforeDraw = true + const styleManager = new DefaultStyleManager() + let releaseFonts!: () => void + const fontsReady = new Promise(resolve => { + releaseFonts = resolve + }) + const requestFonts = vi + .spyOn(FontManager.instance, 'requestFonts') + .mockImplementation(async () => { + await fontsReady + return [] + }) + const mtext = new MText( + minimalMTextData, + minimalTextStyle, + styleManager, + FontManager.instance, + createDefaultColorSettings() + ) + + let drawSettled = false + const drawPromise = mtext.asyncDraw().then(() => { + drawSettled = true + }) + + await Promise.resolve() + expect(drawSettled).toBe(false) + expect(requestFonts).toHaveBeenCalledWith( + expect.arrayContaining(['arial', 'txt', 'hztxt']) + ) + + releaseFonts() + await drawPromise + expect(drawSettled).toBe(true) + requestFonts.mockRestore() + }) + + it('MText.asyncDraw awaits fonts when options.awaitFonts is true', async () => { + const styleManager = new DefaultStyleManager() + let releaseFonts!: () => void + const fontsReady = new Promise(resolve => { + releaseFonts = resolve + }) + const requestFonts = vi + .spyOn(FontManager.instance, 'requestFonts') + .mockImplementation(async () => { + await fontsReady + return [] + }) + const mtext = new MText( + minimalMTextData, + minimalTextStyle, + styleManager, + FontManager.instance, + createDefaultColorSettings() + ) + + let drawSettled = false + const drawPromise = mtext.asyncDraw({ awaitFonts: true }).then(() => { + drawSettled = true + }) + + await Promise.resolve() + expect(drawSettled).toBe(false) + + releaseFonts() + await drawPromise + expect(drawSettled).toBe(true) + requestFonts.mockRestore() + }) + it('MText.asyncDraw still requests style fonts after an empty first draw', async () => { const styleManager = new DefaultStyleManager() const requestFonts = vi.spyOn(FontManager.instance, 'requestFonts') @@ -459,6 +536,25 @@ describe('render remote font loading', () => { renderer.destroy() }) + it('WebWorkerRenderer setAwaitFontsBeforeDraw mirrors flag to all workers', async () => { + const renderer = new WebWorkerRenderer({ poolSize: 2, timeOut: 5000 }) + + await renderer.setAwaitFontsBeforeDraw(true) + + expect(FontManager.instance.awaitFontsBeforeDraw).toBe(true) + for (const worker of workerInstances) { + expect(worker.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'setAwaitFontsBeforeDraw', + data: { enabled: true } + }) + ) + } + + await renderer.setAwaitFontsBeforeDraw(false) + renderer.destroy() + }) + it('WebWorkerRenderer loadFonts delegates font names to all workers', async () => { const renderer = new WebWorkerRenderer({ poolSize: 2, timeOut: 5000 })