diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index 2fa038ce..0665e274 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -173,6 +173,7 @@ export class App { } #openLauncher(): Window { + this.#workspaces.prepareOpen(); const window = this.#createWindow(); this.#loadLauncher(window); return window; @@ -299,6 +300,7 @@ export class App { } async #openWorkspaceFromWindow(opener: Window): Promise { + this.#workspaces.prepareOpen(); const openPath = await showOpenFontDialog(opener); if (!openPath) return; diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index 3a905568..55b3eefa 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -9,7 +9,7 @@ Electron main process: app startup, windows, menus, document dialogs, and worksp - **Architecture Invariant:** Dirty state and save targets come from the utility-owned workspace state. Main shows native dialogs, but state reads, saves, and exports go through the renderer document lane so pending edits flush first. - **Architecture Invariant:** TTF export snapshots the workspace in the ordered sync lane, then releases that lane before font compilation so subsequent editing is not blocked by fontc. - **Architecture Invariant:** A `.shift` package session is reused by `(packageId, canonicalPath)`, not by the path string the user selected and not by the current document id. -- **Architecture Invariant:** Closing the last window for a workspace runs `DocumentSession.confirmClose`. Clean documents and explicitly discarded dirty documents are closed through the utility process so package bindings and SQLite documents are pruned. +- **Architecture Invariant:** Closing the last window for a workspace runs `DocumentSession.confirmClose`. Clean package-backed SQLite documents remain bound for directory-first reopen; untitled/imported documents and explicitly discarded dirty documents are pruned. - **Architecture Invariant:** Closing every window keeps the application alive on macOS. Activating the windowless app opens a fresh launcher; Windows and Linux quit after the last window closes. - **Architecture Invariant:** IPC channels are type-safe. `ipcMain.handle` calls use the typed wrapper from `shared/ipc/main`, and channel names and payload types live in `shared/ipc/contract.ts` and `shared/workspace/protocol.ts`. @@ -60,9 +60,9 @@ On macOS, closing the last window leaves Shift running. A later Dock activation ### Workspace Creation And Open -File -> New asks `WorkspaceManager.createUntitled()` for a session. File -> Open shows `showOpenFontDialog()` and then asks `WorkspaceManager.openPath(path)`. +File -> New asks `WorkspaceManager.createUntitled()` for a session. The launcher prepares an idle utility process, so File -> Open overlaps process startup with `showOpenFontDialog()` before asking `WorkspaceManager.openPath(path)`. -For `.shift` paths, `WorkspaceManager` starts a provisional utility process and calls `workspace.inspectPackage` before opening. If a live session already owns the same `(packageId, canonicalPath)`, the provisional process is stopped and the existing session is returned. Otherwise the process opens the package and the resulting state is registered. +For `.shift` paths, `WorkspaceManager` calls `workspace.inspectPackage` before opening. If a live session already owns the same `(packageId, canonicalPath)`, the provisional process is stopped and the existing session is returned. Otherwise the inspected identity is passed into the open request instead of reading and hashing the package a second time. A matching clean or dirty working document resumes directory-first; a divergent clean document is replaced, while a divergent dirty document is orphaned. After main receives the open response, it sends a fire-and-forget complete authored glyph compilation request so layer acquisition and Slug work overlap workspace-window and WebGPU startup; the grid still waits for the complete atlas and never renders placeholder glyphs. ### Window Attachment @@ -74,7 +74,7 @@ Save and Save As start in `DocumentSession`, but the actual save request goes th Export TrueType follows the same document and sync lanes. The utility process captures an immutable native snapshot after prior edits, then awaits direct Shift IR-to-fontc compilation outside the workspace queue. Edits submitted after snapshot capture can proceed and are not included in that export. Export does not change the package binding or dirty state. -Close and quit call `DocumentSession.confirmClose`. If the document is clean, or the user saves successfully, or the user chooses discard, `DocumentSession` calls `workspace.close` in the utility process. The utility drops the Rust workspace handle, removes package bindings, and deletes the clean/discarded SQLite document. Dirty divergent documents created by package-source conflicts are orphaned by the utility process, not by main. +Close and quit call `DocumentSession.confirmClose`. If the document is clean, or the user saves successfully, or the user chooses discard, `DocumentSession` calls `workspace.close` in the utility process. The utility drops the Rust workspace handle but retains a clean package-backed binding and SQLite document for the next open. Untitled/imported documents and explicitly discarded dirty package documents are deleted. Dirty divergent documents created by package-source conflicts are orphaned by the utility process, not by main. Message lanes reject in-flight calls when their remote port closes. An unexpected utility-process exit also disconnects the renderer document lane: Save remains blocked because pending edits cannot be settled, while an explicit Discard treats the unavailable workspace as already closed so window and quit guards can finish. diff --git a/apps/desktop/src/main/workspace/WorkspaceManager.ts b/apps/desktop/src/main/workspace/WorkspaceManager.ts index 170b2b94..f3c1a032 100644 --- a/apps/desktop/src/main/workspace/WorkspaceManager.ts +++ b/apps/desktop/src/main/workspace/WorkspaceManager.ts @@ -31,6 +31,7 @@ export class WorkspaceManager { readonly #sessionsById = new Map(); readonly #sessionIdByWindowId = new Map(); readonly #packageSessions = new PackageSessionIndex(); + #preparedProcess: WorkspaceProcess | null = null; /** * Creates a manager for live font workspace sessions. @@ -42,6 +43,15 @@ export class WorkspaceManager { this.#applicationName = options.applicationName; } + /** Starts an idle utility process so source selection can hide its startup cost. */ + prepareOpen(): void { + if (this.#preparedProcess) return; + + const workspaceProcess = new WorkspaceProcess(); + workspaceProcess.start(this.#documentsRoot()); + this.#preparedProcess = workspaceProcess; + } + /** * Creates an untitled workspace session. * @@ -58,7 +68,8 @@ export class WorkspaceManager { * @returns a live session for the opened source; existing sessions are reused by workspace id. */ async openPath(sourcePath: string): Promise { - const workspaceProcess = new WorkspaceProcess(); + const workspaceProcess = this.#preparedProcess ?? new WorkspaceProcess(); + this.#preparedProcess = null; workspaceProcess.start(this.#documentsRoot()); try { @@ -73,7 +84,7 @@ export class WorkspaceManager { return existingBeforeOpen; } - const state = await workspaceProcess.openWorkspace(sourcePath); + const state = await workspaceProcess.openWorkspace(sourcePath, identity); const existingAfterOpen = this.#sessionForDocumentState(state); if (existingAfterOpen) { workspaceProcess.stop(); @@ -81,6 +92,7 @@ export class WorkspaceManager { return existingAfterOpen; } + workspaceProcess.prepareAuthoredGlyphCompilation(); return this.#registerLoadedSession(workspaceProcess, state); } catch (error) { workspaceProcess.stop(); @@ -202,7 +214,8 @@ export class WorkspaceManager { async #createSession( load: (workspaceProcess: WorkspaceProcess) => Promise, ): Promise { - const workspaceProcess = new WorkspaceProcess(); + const workspaceProcess = this.#preparedProcess ?? new WorkspaceProcess(); + this.#preparedProcess = null; workspaceProcess.start(this.#documentsRoot()); try { diff --git a/apps/desktop/src/main/workspace/WorkspaceProcess.ts b/apps/desktop/src/main/workspace/WorkspaceProcess.ts index dce519b5..c05eda21 100644 --- a/apps/desktop/src/main/workspace/WorkspaceProcess.ts +++ b/apps/desktop/src/main/workspace/WorkspaceProcess.ts @@ -112,11 +112,28 @@ export class WorkspaceProcess { * Opens a workspace from a source path through the shell lane. * * @param path - User-selected source path to open. + * @param packageIdentity - Previously inspected identity for a `.shift` package. * @returns utility-owned document state for the opened workspace. * @throws {Error} when the utility process is not running or rejects the call. */ - openWorkspace(path: string): Promise { - return this.#requireChannel().call("workspace.open", { path }); + openWorkspace( + path: string, + packageIdentity: WorkspacePackageIdentity | null = null, + ): Promise { + const request = packageIdentity ? { path, packageIdentity } : { path }; + return this.#requireChannel().call("workspace.open", request); + } + + /** Starts complete authored glyph compilation without delaying workspace-window creation. */ + prepareAuthoredGlyphCompilation(): void { + const preparation = this.#requireChannel().call( + "workspace.prepareAuthoredGlyphCompilation", + undefined, + ); + // This is a genuine fire-and-forget boundary: renderer startup consumes the result later. + void preparation.catch((error: unknown) => { + this.#log.error("authored glyph compilation failed", error); + }); } /** diff --git a/apps/desktop/src/shared/workspace/protocol.ts b/apps/desktop/src/shared/workspace/protocol.ts index 4d6ee968..a2972caf 100644 --- a/apps/desktop/src/shared/workspace/protocol.ts +++ b/apps/desktop/src/shared/workspace/protocol.ts @@ -125,9 +125,10 @@ export type ShellCallMap = { response: WorkspacePackageIdentity; }; "workspace.open": { - request: { path: string }; + request: { path: string; packageIdentity?: WorkspacePackageIdentity }; response: WorkspaceDocumentState; }; + "workspace.prepareAuthoredGlyphCompilation": { request: void; response: void }; "workspace.close": { request: { discard: boolean }; response: null }; "workspace.connect": { request: void; response: void }; "document.state": { request: void; response: WorkspaceDocumentState | null }; diff --git a/apps/desktop/src/utility/workspace/PackageOpener.test.ts b/apps/desktop/src/utility/workspace/PackageOpener.test.ts index 37dc0bc2..155b45bd 100644 --- a/apps/desktop/src/utility/workspace/PackageOpener.test.ts +++ b/apps/desktop/src/utility/workspace/PackageOpener.test.ts @@ -144,20 +144,37 @@ describe("PackageOpener preserves package-backed document bindings", () => { }); }); - it("replaces a clean binding with a fresh document", () => { - const { identity } = createPackageSource("Replace.shift"); + it("resumes a clean binding when the package fingerprint still matches", () => { + const { identity } = createPackageSource("ResumeClean.shift"); const firstBridge = makeBridge(); const firstOpen = openPackage(firstBridge, identity); - const oldDocumentPath = path.dirname(firstOpen.document.storePath); firstBridge.closeWorkspace(); const nextBridge = makeBridge(); const reopened = openPackage(nextBridge, identity); + expect(reopened.document.documentId).toBe(firstOpen.document.documentId); + expect(glyphNames(nextBridge)).toEqual(["A"]); + expect(nextBridge.documentState()).toMatchObject({ dirty: false }); + expect(storage.packageBinding(reopened.address)?.documentId).toBe( + firstOpen.document.documentId, + ); + }); + + it("replaces a clean binding when the source package diverges", () => { + const { sourcePath, identity } = createPackageSource("ReplaceClean.shift"); + const firstBridge = makeBridge(); + const firstOpen = openPackage(firstBridge, identity); + const oldDocumentPath = path.dirname(firstOpen.document.storePath); + const divergedIdentity = externallyAddGlyph(sourcePath, "C" as GlyphName, 67 as Unicode); + firstBridge.closeWorkspace(); + const nextBridge = makeBridge(); + + const reopened = openPackage(nextBridge, divergedIdentity); + expect(reopened.document.documentId).not.toBe(firstOpen.document.documentId); expect(fs.existsSync(oldDocumentPath)).toBe(false); - expect(glyphNames(nextBridge)).toEqual(["A"]); - expect(storage.packageBinding(reopened.address)?.documentId).toBe(reopened.document.documentId); + expect(glyphNames(nextBridge)).toEqual(["A", "C"]); }); it("orphans a dirty binding when the source package diverges", () => { diff --git a/apps/desktop/src/utility/workspace/PackageOpener.ts b/apps/desktop/src/utility/workspace/PackageOpener.ts index 94b437ff..f4b146fa 100644 --- a/apps/desktop/src/utility/workspace/PackageOpener.ts +++ b/apps/desktop/src/utility/workspace/PackageOpener.ts @@ -15,8 +15,8 @@ import { * * @remarks * Package opens resolve through a small action machine over the durable binding, - * the source fingerprint, and the saved draft metadata. Dirty matching drafts - * resume; clean drafts are replaceable; dirty divergent drafts are orphaned + * the source fingerprint, and the saved draft metadata. Matching drafts resume; + * clean divergent drafts are replaceable; dirty divergent drafts are orphaned * before a fresh hydrate. Moved packages resume only when exactly one missing * old path matches the package id and base fingerprint. */ @@ -76,8 +76,8 @@ export class PackageOpener { ); } - if (!draft.dirty) return { kind: "replace", binding }; if (draft.baseFingerprint === identity.fingerprint) return { kind: "resume", binding }; + if (!draft.dirty) return { kind: "replace", binding }; return { kind: "orphan", binding }; } diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts index cf50fa0f..3e955256 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts @@ -195,7 +195,16 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { targetShell: ShellChannel, sourcePath: string, ): Promise { - const state = await targetShell.call("workspace.open", { path: sourcePath }); + const request = + path.extname(sourcePath).toLowerCase() === ".shift" + ? { + path: sourcePath, + packageIdentity: await targetShell.call("workspace.inspectPackage", { + path: sourcePath, + }), + } + : { path: sourcePath }; + const state = await targetShell.call("workspace.open", request); const snapshot = await sync.call("workspace.snapshot", undefined); if (!snapshot) throw new Error("workspace.open did not create a snapshot"); expect(snapshot.documentId).toBe(state.documentId); @@ -232,6 +241,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { const snapshot = await createWorkspace(sync); const glyph = createGlyphALayer(snapshot.sources[0]!.id); await applyWorkspace(sync, { intents: glyph.intents }); + await shell.call("workspace.prepareAuthoredGlyphCompilation", undefined); const atlas = await sync.call("workspace.slugAtlasPrepare", { alignment: 256 }); const bytes = await streamSlugAtlas(sync, atlas.generation, 64); @@ -493,13 +503,14 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { }); }); - it("opening a clean package binding hydrates a fresh document", async () => { + it("opening a clean package binding resumes its document", async () => { const source = await connectSyncLane(); const created = await createWorkspace(source); await source.call("workspace.apply", { intents: [createGlyphA()], label: "Add Glyph" }); const savePath = path.join(tmpRoot, "CleanBinding.shift"); await source.call("workspace.saveAs", { path: savePath }); const oldStorePath = path.join(tmpRoot, "documents", created.documentId, "document.sqlite"); + await shell.call("workspace.close", { discard: false }); const lane = new MessageChannel(); const restartedShell: ShellChannel = new Channel(nodePortTransport(lane.port1)); @@ -509,9 +520,9 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { const opened = await openWorkspace(restarted, restartedShell, savePath); - expect(opened.documentId).not.toBe(created.documentId); + expect(opened.documentId).toBe(created.documentId); expect(opened.glyphs.map((glyph) => glyph.name)).toEqual(["A"]); - expect(fs.existsSync(oldStorePath)).toBe(false); + expect(fs.existsSync(oldStorePath)).toBe(true); await expect(restartedShell.call("document.state", undefined)).resolves.toMatchObject({ packageId: expect.stringMatching(/^package_/), canonicalPath: fs.realpathSync(savePath), diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.ts index 80963647..2c691775 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.ts @@ -67,7 +67,10 @@ export class WorkspaceHost { this.#shell = serveChannel(this.#shellTransport, { "workspace.create": () => this.#serialize(() => this.#create()), "workspace.inspectPackage": ({ path }) => this.#serialize(() => this.#inspectPackage(path)), - "workspace.open": ({ path }) => this.#serialize(() => this.#open(path)), + "workspace.open": ({ path, packageIdentity }) => + this.#serialize(() => this.#open(path, packageIdentity)), + "workspace.prepareAuthoredGlyphCompilation": () => + this.#serialize(() => this.#bridge.prepareAuthoredGlyphCompilation()), "workspace.close": ({ discard }) => this.#serialize(() => this.#close(discard)), "workspace.connect": (_payload, context) => { this.#connectSyncLane(context.ports); @@ -212,10 +215,14 @@ export class WorkspaceHost { }; } - #open(sourcePath: string): WorkspaceDocumentState { + #open( + sourcePath: string, + packageIdentity: WorkspacePackageIdentity | undefined, + ): WorkspaceDocumentState { if (isShiftPackagePath(sourcePath)) { - return this.#openPackage(sourcePath); + return this.#openPackage(sourcePath, packageIdentity); } + if (packageIdentity) throw new Error("package identity requires a .shift source path"); const document = this.#documents.createDocument(); this.#bridge.openWorkspace(sourcePath, document.storePath); @@ -226,8 +233,11 @@ export class WorkspaceHost { return this.#emitDocumentChanged(); } - #openPackage(sourcePath: string): WorkspaceDocumentState { - const identity = this.#inspectPackage(sourcePath); + #openPackage( + sourcePath: string, + packageIdentity: WorkspacePackageIdentity | undefined, + ): WorkspaceDocumentState { + const identity = packageIdentity ?? this.#inspectPackage(sourcePath); const opened = this.#packageOpener.open(identity); this.#adoptDocument(opened.document, opened.address); @@ -288,8 +298,10 @@ export class WorkspaceHost { this.#documentId = null; this.#packageAddress = null; - if (address) this.#documents.removePackageBinding(address); - this.#documents.deleteDocument(documentId); + if (!address || discard) { + if (address) this.#documents.removePackageBinding(address); + this.#documents.deleteDocument(documentId); + } this.#shell?.emit("document.changed", null); this.#sync?.emit("document.changed", null); diff --git a/crates/shift-bridge/__test__/index.spec.mjs b/crates/shift-bridge/__test__/index.spec.mjs index ba3a4390..0f2eb942 100644 --- a/crates/shift-bridge/__test__/index.spec.mjs +++ b/crates/shift-bridge/__test__/index.spec.mjs @@ -159,9 +159,10 @@ describe("Bridge", () => { bridge.resumeWorkspaceForSource(storePath, packagePath); } - it("acquires every lazy glyph payload before preparing the complete Slug atlas", () => { + it("consumes a prewarmed authored compilation when preparing the complete Slug atlas", () => { resumeMutatorPackage(); + bridge.prepareAuthoredGlyphCompilation(); const atlas = bridge.prepareSlugAtlas(256); expect(atlas.glyphs).toHaveLength(bridge.getGlyphs().length); expect(atlas.curveCount).toBeGreaterThan(0); diff --git a/crates/shift-bridge/docs/DOCS.md b/crates/shift-bridge/docs/DOCS.md index a0f2f215..236d48e1 100644 --- a/crates/shift-bridge/docs/DOCS.md +++ b/crates/shift-bridge/docs/DOCS.md @@ -14,13 +14,13 @@ NAPI bindings that expose the Rust font engine to Node.js and Electron as a `Bri **Architecture Invariant:** Export explicitly acquires all persisted layers before taking a clone/COW `FontSaveSnapshot`. **WHY:** Async export gets a complete stable view while ordinary workspace open remains directory-first and lazy. -**Architecture Invariant:** Glyph snapshot, projection, preview, and Slug preparation methods are explicit acquisition boundaries. They may load requested BLOBs in the serialized utility process; retained renderer glyph objects and synchronous getters never initiate I/O. Component closure comes from the relational reference index rather than BLOB scans, and acquired payloads remain resident in the workspace cache. +**Architecture Invariant:** Glyph snapshot, projection, preview, and Slug preparation methods are explicit acquisition boundaries. They may load requested BLOBs in the serialized utility process; retained renderer glyph objects and synchronous getters never initiate I/O. Component closure comes from the relational reference index rather than BLOB scans, and acquired payloads remain resident in the workspace cache. `prepareAuthoredGlyphCompilation()` may perform complete acquisition before WebGPU initialization so the later aligned atlas request does not repeat that work. **Architecture Invariant:** `shift-font` constructs typed glyph and source-metric interpolation; renderer code only evaluates their flattened transport snapshots. **WHY:** Per-location canvas work stays cheap without moving variation-model construction or value-layout ownership into transport code. **Architecture Invariant:** Package inspection methods are read-only and may run without an open workspace. **WHY:** Electron main/utility code must inspect package identity before deciding whether to reuse, hydrate, relink, or orphan a working document. -**Architecture Invariant:** A prepared Slug atlas or page remains native and is consumed once through napi-rs `ReadableStream` chunks. The native producer has capacity one, and Electron acknowledges each GPU write before the utility reads another chunk. **WHY:** Product upload must retain one bounded temporary chunk, not an atlas-sized JavaScript copy or an unbounded IPC queue. +**Architecture Invariant:** A prepared authored glyph compilation remains native until `prepareSlugAtlas(alignment)` supplies its device alignment. The resulting Slug atlas or page is consumed once through napi-rs `ReadableStream` chunks. Every font edit invalidates both forms. The native producer has capacity one, and Electron acknowledges each GPU write before the utility reads another chunk. **WHY:** Renderer startup can overlap complete authored compilation without placeholders, while product upload retains one bounded temporary chunk rather than an atlas-sized JavaScript copy or an unbounded IPC queue. ## Codemap @@ -31,6 +31,8 @@ crates/shift-bridge/ bridge.rs -- `Bridge` NAPI class, workspace lifecycle, font reads, mutations, export task errors.rs -- bridge error type and NAPI mapping input.rs -- boundary parsing/adaptation helpers + scripts/ + profile-slug-atlas.mjs -- release profiler through `Bridge.prepareSlugAtlas` Cargo.toml -- cdylib crate; depends on shift-font, shift-wire, shift-backends, napi ``` @@ -51,7 +53,8 @@ crates/shift-bridge/ - `NapiGlyphProjection` -- compact location-independent glyph backing with reusable interpolation, exact-source exceptions, and Rust-owned `GlyphComponents` relationships. - `NapiSourceMetricsInterpolationSnapshot` -- metric schema, reusable interpolation basis, and ordered source values projected from native source-metric interpolation; derived state, never `.shift` authoring data. - `NapiSlugAtlas` -- small generation/page metadata, explicit authored root identities, exact-source selectors, deduplicated weight bases, and aligned resident-section layout. -- `SlugAtlasGeneration` -- one prepared native atlas or page consumed by its stream API or released by its discard API. +- `authoredGlyphCompilation` -- one complete location-independent `AuthoredAtlas` prepared before device alignment and consumed by the complete-atlas endpoint. +- `SlugAtlasGeneration` -- one aligned native atlas or page consumed by its stream API or released by its discard API. ## How it works @@ -62,9 +65,9 @@ crates/shift-bridge/ 5. Full glyph snapshots first acquire requested layer payloads, then include authored state plus the same `GlyphProjection` used by lightweight reads. `getGlyphProjections()` and previews expand transitive component identities through SQLite indexes, acquire those layers, and only then project without further I/O. Source reads expose master sources only; layer-only/background sources remain native authoring details and never enter renderer interpolation. 6. `saveWorkspace()` / `saveWorkspaceAs(path)` update the `.shift` source package target and record the persisted version. 7. `inspectPackage(path)` and `inspectPackageDraft(storePath)` expose source/package identity for the utility process without choosing a recovery policy. -8. `closeWorkspace()` drops the live Rust workspace handle before the utility process deletes a clean or discarded SQLite document. +8. `closeWorkspace()` drops the live Rust workspace handle. The utility process retains a clean package-backed SQLite document, but deletes untitled/imported documents and explicitly discarded dirty documents. 9. `exportWorkspace(request)` creates a `FontSaveSnapshot` and exports asynchronously through `shift-backends`. -10. `prepareSlugAtlas(alignment)` acquires all layers for the catalog's complete resident generation. `prepareSlugAtlasPage(glyphIds, alignment)` acquires the ordered roots and their indexed component closures for a local edit patch. Acquired payloads remain in the workspace cache, while stream/discard methods retain the bounded atlas transport contract. Every font edit invalidates an unconsumed generation or patch. +10. After source open, `prepareAuthoredGlyphCompilation()` acquires all layers and builds the catalog's complete location-independent `AuthoredAtlas` while the renderer starts. `prepareSlugAtlas(alignment)` consumes that prepared compilation and performs only alignment-specific layout; if prewarming has not run, it performs the same complete build synchronously. `prepareSlugAtlasPage(glyphIds, alignment)` independently acquires ordered roots and their indexed component closures for a local edit patch. Each build uses one compilation-scoped `GlyphProjectionSet`; no projection or resolved-source map survives its build. Every font edit invalidates the prepared compilation and any unconsumed generation or patch. Background compilation emits a `[workspace-open]` acquisition/compilation summary; set `SHIFT_PROFILE_SLUG_ATLAS=1` for every nested native phase. ## Type Boundary @@ -99,6 +102,7 @@ cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace pnpm --filter shift-bridge run build:debug pnpm generate:bridge-types +SHIFT_PROFILE_SLUG_ATLAS=1 node crates/shift-bridge/scripts/profile-slug-atlas.mjs /path/to/font.shift 10 5 ``` ## Related diff --git a/crates/shift-bridge/index.d.ts b/crates/shift-bridge/index.d.ts index 039378c2..9efa1951 100644 --- a/crates/shift-bridge/index.d.ts +++ b/crates/shift-bridge/index.d.ts @@ -69,6 +69,14 @@ export declare class Bridge { * `get_glyph_snapshots`. */ getGlyphPreviews(glyphIds: Array, location: NapiLocation): Array + /** + * Acquires every authored layer and compiles the complete location-independent atlas. + * + * The utility process calls this after opening a workspace so acquisition and + * compilation overlap renderer and WebGPU startup. Alignment-specific layout + * remains deferred until `prepare_slug_atlas` knows the adapter requirement. + */ + prepareAuthoredGlyphCompilation(): void /** * Builds one complete authored Slug generation without resolving a location. * diff --git a/crates/shift-bridge/scripts/profile-slug-atlas.mjs b/crates/shift-bridge/scripts/profile-slug-atlas.mjs new file mode 100644 index 00000000..bad4de4d --- /dev/null +++ b/crates/shift-bridge/scripts/profile-slug-atlas.mjs @@ -0,0 +1,133 @@ +import { createRequire } from "node:module"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { performance } from "node:perf_hooks"; + +const require = createRequire(import.meta.url); +const { Bridge } = require("../index.js"); + +const sourceArgument = process.argv[2]; +if (!sourceArgument) { + throw new Error( + "usage: node scripts/profile-slug-atlas.mjs [warm-iterations] [resume-iterations]", + ); +} + +const warmIterations = Number.parseInt(process.argv[3] ?? "10", 10); +if (!Number.isSafeInteger(warmIterations) || warmIterations < 1) { + throw new Error(`invalid warm iteration count: ${process.argv[3]}`); +} +const resumeIterations = Number.parseInt(process.argv[4] ?? "5", 10); +if (!Number.isSafeInteger(resumeIterations) || resumeIterations < 1) { + throw new Error(`invalid resume iteration count: ${process.argv[4]}`); +} + +const sourcePath = isAbsolute(sourceArgument) ? sourceArgument : resolve(sourceArgument); +const tempDirectory = mkdtempSync(join(tmpdir(), "shift-slug-profile-")); +const storePath = join(tempDirectory, "working.sqlite"); +const packagePath = join(tempDirectory, "profile.shift"); +const bridge = new Bridge(); +const bridges = [bridge]; + +function measure(operation) { + const started = performance.now(); + const value = operation(); + return { value, milliseconds: performance.now() - started }; +} + +function prepare(targetBridge) { + const { value: atlas, milliseconds } = measure(() => targetBridge.prepareSlugAtlas(256)); + const signature = { + rootGlyphs: atlas.glyphs.length, + atlasGlyphs: atlas.atlasGlyphCount, + curves: atlas.curveCount, + components: atlas.componentCount, + bytes: atlas.layout.totalLength, + }; + targetBridge.discardSlugAtlas(atlas.generation); + return { milliseconds, signature }; +} + +function percentile(sorted, fraction) { + return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)] ?? 0; +} + +function statistics(samples) { + const sorted = [...samples].sort((a, b) => a - b); + return { + p50: percentile(sorted, 0.5), + p95: percentile(sorted, 0.95), + samples: sorted, + }; +} + +function verifySignature(actual, expected) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error("Slug atlas counts changed between preparations"); + } +} + +try { + const opened = measure(() => bridge.openWorkspace(sourcePath, storePath)); + const cold = prepare(bridge); + bridge.saveWorkspaceAs(packagePath); + bridge.closeWorkspace(); + + const resumeSamples = []; + const authoredCompilationSamples = []; + const alignedPrepareSamples = []; + const previewSamples = []; + let warmBridge; + for (let index = 0; index < resumeIterations; index++) { + const resumedBridge = new Bridge(); + bridges.push(resumedBridge); + const resumed = measure(() => resumedBridge.resumeWorkspaceForSource(storePath, packagePath)); + const authoredCompilation = measure(() => resumedBridge.prepareAuthoredGlyphCompilation()); + const alignedPrepare = prepare(resumedBridge); + verifySignature(alignedPrepare.signature, cold.signature); + + resumeSamples.push(resumed.milliseconds); + authoredCompilationSamples.push(authoredCompilation.milliseconds); + alignedPrepareSamples.push(alignedPrepare.milliseconds); + previewSamples.push(authoredCompilation.milliseconds + alignedPrepare.milliseconds); + + if (index === resumeIterations - 1) { + warmBridge = resumedBridge; + } else { + resumedBridge.closeWorkspace(); + } + } + + const warm = Array.from({ length: warmIterations }, () => prepare(warmBridge)); + for (const sample of warm) verifySignature(sample.signature, cold.signature); + + console.log( + JSON.stringify( + { + sourcePath, + warmIterations, + resumeIterations, + openMs: opened.milliseconds, + coldPrepareMs: cold.milliseconds, + resumeMs: statistics(resumeSamples), + resumedAuthoredCompilationMs: statistics(authoredCompilationSamples), + resumedAlignedPrepareMs: statistics(alignedPrepareSamples), + resumedPreviewMs: statistics(previewSamples), + warmPrepareMs: statistics(warm.map((sample) => sample.milliseconds)), + signature: cold.signature, + }, + null, + 2, + ), + ); +} finally { + for (const openBridge of bridges) { + try { + openBridge.closeWorkspace(); + } catch { + // The workspace may not have opened or may already be closed. + } + } + rmSync(tempDirectory, { recursive: true, force: true }); +} diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index 729a61f9..943838a1 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -14,8 +14,8 @@ use shift_font::{ NamedInstance as FontNamedInstance, NamedInstanceId, PointId, PointSeed, SourceId, }; use shift_slug::{ - build_authored_atlas, build_authored_atlas_page, AuthoredAtlas, Section as SlugSection, - VariableAtlas, VariableLayout, + build_authored_atlas_page_profiled, build_authored_atlas_profiled, AuthoredAtlas, + AuthoredAtlasProfile, Section as SlugSection, VariableAtlas, VariableLayout, }; use shift_wire::{ bridges::napi::{ @@ -39,6 +39,7 @@ use std::{ collections::{HashSet, VecDeque}, path::Path, sync::Arc, + time::{Duration, Instant}, }; #[napi(object)] @@ -239,6 +240,39 @@ fn napi_slug_layout(layout: VariableLayout) -> BridgeResult { }) } +fn slug_atlas_profile_enabled() -> bool { + std::env::var("SHIFT_PROFILE_SLUG_ATLAS").is_ok_and(|value| value != "0") +} + +fn milliseconds(duration: Duration) -> f64 { + duration.as_secs_f64() * 1_000.0 +} + +fn log_slug_atlas_profile( + scope: &str, + acquisition: Duration, + profile: AuthoredAtlasProfile, + layout: Duration, + total: Duration, +) { + if !slug_atlas_profile_enabled() { + return; + } + + eprintln!( + "[slug-atlas] scope={scope} acquisition_ms={:.3} projection_preparation_ms={:.3} weight_set_collection_ms={:.3} component_preparation_ms={:.3} fallback_bounds_ms={:.3} exact_source_preparation_ms={:.3} atlas_addition_ms={:.3} layout_ms={:.3} total_ms={:.3}", + milliseconds(acquisition), + milliseconds(profile.projection_preparation), + milliseconds(profile.weight_set_collection), + milliseconds(profile.component_preparation), + milliseconds(profile.fallback_bounds), + milliseconds(profile.exact_source_preparation), + milliseconds(profile.atlas_addition), + milliseconds(layout), + milliseconds(total), + ); +} + fn napi_slug_atlas( generation: u32, authored: &AuthoredAtlas, @@ -431,6 +465,7 @@ pub struct Bridge { saved_version: DocumentVersion, slug_generation: u32, slug_atlas: Option, + authored_glyph_compilation: Option, } #[napi] @@ -443,6 +478,7 @@ impl Bridge { saved_version: DocumentVersion::default(), slug_generation: 0, slug_atlas: None, + authored_glyph_compilation: None, } } @@ -834,21 +870,81 @@ impl Bridge { Ok(previews) } + /// Acquires every authored layer and compiles the complete location-independent atlas. + /// + /// The utility process calls this after opening a workspace so acquisition and + /// compilation overlap renderer and WebGPU startup. Alignment-specific layout + /// remains deferred until `prepare_slug_atlas` knows the adapter requirement. + #[napi] + pub fn prepare_authored_glyph_compilation(&mut self) -> errors::Result<()> { + if self.authored_glyph_compilation.is_some() || self.slug_atlas.is_some() { + return Ok(()); + } + + let total_started = Instant::now(); + let started = Instant::now(); + self.workspace_mut()?.acquire_all_layers()?; + let acquisition = started.elapsed(); + let started = Instant::now(); + let (authored, profile) = + build_authored_atlas_profiled(self.font()?, shift_slug::DEFAULT_BAND_COUNT)?; + let compilation = started.elapsed(); + println!( + "[workspace-open] mode=authored-glyph-compilation acquisition_ms={:.3} compilation_ms={:.3} total_ms={:.3}", + milliseconds(acquisition), + milliseconds(compilation), + milliseconds(total_started.elapsed()), + ); + log_slug_atlas_profile( + "authored-prewarm", + acquisition, + profile, + Duration::ZERO, + total_started.elapsed(), + ); + self.authored_glyph_compilation = Some(authored); + Ok(()) + } + /// Builds one complete authored Slug generation without resolving a location. /// /// The returned metadata is small enough for the ordinary sync lane. Packed /// geometry remains native until `stream_slug_atlas` emits bounded chunks. #[napi] pub fn prepare_slug_atlas(&mut self, alignment: u32) -> errors::Result { - self.workspace_mut()?.acquire_all_layers()?; - let authored = build_authored_atlas(self.font()?, shift_slug::DEFAULT_BAND_COUNT)?; + let total_started = Instant::now(); + let (authored, acquisition, profile, scope) = + if let Some(authored) = self.authored_glyph_compilation.take() { + ( + authored, + Duration::ZERO, + AuthoredAtlasProfile::default(), + "complete-prepared", + ) + } else { + let started = Instant::now(); + self.workspace_mut()?.acquire_all_layers()?; + let acquisition = started.elapsed(); + let (authored, profile) = + build_authored_atlas_profiled(self.font()?, shift_slug::DEFAULT_BAND_COUNT)?; + (authored, acquisition, profile, "complete") + }; + let started = Instant::now(); let layout = authored.atlas().layout(alignment as usize)?; + let layout_elapsed = started.elapsed(); self.slug_generation = self .slug_generation .checked_add(1) .ok_or(shift_slug::SlugError::LengthOverflow)?; let generation = self.slug_generation; let result = napi_slug_atlas(generation, &authored, layout)?; + log_slug_atlas_profile( + scope, + acquisition, + profile, + layout_elapsed, + total_started.elapsed(), + ); self.slug_atlas = Some(SlugAtlasGeneration { generation, alignment: alignment as usize, @@ -867,19 +963,32 @@ impl Bridge { glyph_ids: Vec, alignment: u32, ) -> errors::Result { + let total_started = Instant::now(); let glyph_ids = glyph_ids .iter() .map(|glyph_id| parse::(glyph_id)) .collect::>>()?; + let started = Instant::now(); let font = self.acquire_and_font(&glyph_ids, AcquireScope::ComponentClosure)?; - let authored = build_authored_atlas_page(font, &glyph_ids, shift_slug::DEFAULT_BAND_COUNT)?; + let acquisition = started.elapsed(); + let (authored, profile) = + build_authored_atlas_page_profiled(font, &glyph_ids, shift_slug::DEFAULT_BAND_COUNT)?; + let started = Instant::now(); let layout = authored.atlas().layout(alignment as usize)?; + let layout_elapsed = started.elapsed(); self.slug_generation = self .slug_generation .checked_add(1) .ok_or(shift_slug::SlugError::LengthOverflow)?; let generation = self.slug_generation; let result = napi_slug_atlas(generation, &authored, layout)?; + log_slug_atlas_profile( + "page", + acquisition, + profile, + layout_elapsed, + total_started.elapsed(), + ); self.slug_atlas = Some(SlugAtlasGeneration { generation, alignment: alignment as usize, @@ -1125,6 +1234,7 @@ impl Bridge { fn mark_font_changed(&mut self) { self.slug_atlas = None; + self.authored_glyph_compilation = None; self.bump_live_version(); } @@ -1138,6 +1248,7 @@ impl Bridge { fn reset_versions(&mut self) { self.slug_atlas = None; + self.authored_glyph_compilation = None; self.live_version = DocumentVersion::default(); self.saved_version = DocumentVersion::default(); } @@ -1739,6 +1850,31 @@ mod tests { } } + #[test] + fn authored_glyph_compilation_is_consumed_and_invalidated() { + let mut bridge = bridge_with_workspace(); + + bridge.prepare_authored_glyph_compilation().unwrap(); + assert!(bridge.authored_glyph_compilation.is_some()); + + let prepared = bridge.prepare_slug_atlas(256).unwrap(); + assert!(bridge.authored_glyph_compilation.is_none()); + assert_eq!( + bridge.slug_atlas.as_ref().unwrap().generation, + prepared.generation + ); + + bridge.discard_slug_atlas(prepared.generation); + bridge.prepare_authored_glyph_compilation().unwrap(); + bridge + .apply( + vec![create_glyph_napi("A", vec![65])], + Some("Add Glyph".to_string()), + ) + .unwrap(); + assert!(bridge.authored_glyph_compilation.is_none()); + } + #[test] fn apply_create_glyph_returns_identity_record_without_layers() { let mut bridge = bridge_with_workspace(); diff --git a/crates/shift-font/docs/DOCS.md b/crates/shift-font/docs/DOCS.md index 13cae11a..77bf1909 100644 --- a/crates/shift-font/docs/DOCS.md +++ b/crates/shift-font/docs/DOCS.md @@ -47,7 +47,8 @@ crates/shift-font/src/ - `InterpolationBasis` is coordinate-independent variation math for an ordered source set. It contains normalized supports and source coefficient rows, never glyph coordinates or metrics. - `GlyphInterpolation` combines a reusable basis with one glyph's compatible authored source values. The glyph's default-source layer owns topology when present; otherwise a deterministic master-backed reference layer allows sparse glyph interpolation. - `LayerCompatibility` records every hard structural difference between an interpolation reference layer and another source layer. `LayerDifference` retains ordered path, node, anchor, and component evidence for diagnostics. -- `GlyphProjection` is a compact location-independent glyph payload: fallback layer values, optional compatible interpolation, exact-source topology exceptions, `GlyphComponents`, and transitive component identities. +- `GlyphProjection` is a compact location-independent glyph payload: shared fallback layers, optional compatible interpolation, exact-source topology exceptions, `GlyphComponents`, and transitive component identities. +- `GlyphProjectionSet` is an immutable, read-scoped projection table for requested roots and their transitive components. It prepares each glyph once and reuses interpolation bases keyed by ordered compatible source identities; callers discard it after the current read or compilation. - `GlyphComponents` is the ordered, cycle-pruned component occurrence list for one root glyph. Every `ComponentGlyph` carries its full `ComponentId` ancestry and Rust-selected anchor attachment. - `FontProjection` is a read-only, location-bound view that reuses resolved component layers across one or many glyph requests. - `ResolvedGlyph` is derived, flattened geometry plus x advance. An existing blank glyph resolves to an empty contour list; a missing glyph resolves to `None`. @@ -70,7 +71,7 @@ Stable IDs are identity. Names and Unicode values are editable metadata. - Own font authoring data structures such as `Font`, `Glyph`, `GlyphLayer`, `Contour`, `Point`, `Source`, and `Axis`. - Keep object-level mutation behavior near the objects it mutates. -- Provide model-native helpers for layer editing, component resolution, variation behavior, axis mapping evaluation, and geometry-derived behavior. `Font::replace_glyph_layers` retains previous layers through cheap `Arc` references, validates a complete hydration/eviction batch into one `HashSet`, moves that set into the final index, mutates uniquely owned fonts in place, and preserves shared snapshots through copy-on-write. +- Provide model-native helpers for layer editing, component resolution, variation behavior, axis mapping evaluation, and geometry-derived behavior. `Font::replace_glyph_layers` retains previous layers through cheap `Arc` references, validates a complete hydration/eviction batch into one `HashSet`, moves that set into the final entity index, and mutates uniquely owned fonts in place. Layer ID and source ownership are required to match, so their existing indexes remain valid instead of being removed and reinserted. Shared snapshots retain copy-on-write semantics. - Own canonical glyph and source-metric interpolation value ordering, variation-model construction, interpolation evaluation, and location-bound glyph resolution. - Stay independent of TypeScript, NAPI, and bridge DTOs. @@ -80,7 +81,7 @@ Stable IDs are identity. Names and Unicode values are editable metadata. Coordinates, advance width, smooth flags, anchor positions, and component transforms are interpolated values, not structural compatibility. Matching anchor count, names, and order is currently a Shift-specific restriction because anchor positions share the ordered glyph interpolation vector; OpenType `gvar` and fontTools do not define source-anchor compatibility. Variable component scale or matrix transforms need a separate export diagnostic because `gvar` varies component placement rather than those transforms. Correspondence and quality warnings such as contour order, wrong start point, and kinks are separate from this hard structural result. -`Font::glyph_projection(glyph_id)` preserves the preferred fallback, compatible interpolation, incompatible authored source topology, and Rust-owned component relationships without resolving a location. Each glyph in the component closure resolves independently at the shared root location: exact master, then interpolation, then static master fallback. Layer-only/background sources never supply projection geometry. A renderer can retain this compact payload and combine its basis with current authored source signals. No arbitrary location result is persisted. +`Font::glyph_projection(glyph_id)` preserves the preferred fallback, compatible interpolation, incompatible authored source topology, and Rust-owned component relationships without resolving a location. `Font::glyph_projection_set(glyph_ids)` applies the same semantics to a batch while preparing each requested or transitively referenced glyph once and sharing equal source-location bases. Authored fallback and exact-source layers remain `Arc`-shared; only derived interpolation values are owned by the projections. Each glyph in the component closure resolves independently at the shared root location: exact master, then interpolation, then static master fallback. Layer-only/background sources never supply projection geometry. A renderer can retain this compact payload and combine its basis with current authored source signals. No arbitrary location result is persisted, and projection sets must not survive authored edits. `Font::source_metric_interpolation()` combines the same coordinate-independent basis with complete master-source metric vectors. Optional technical fields participate only when every master authors them, so interpolation does not invent sparse values. diff --git a/crates/shift-font/src/interpolation.rs b/crates/shift-font/src/interpolation.rs index 68cb730b..1d5ba993 100644 --- a/crates/shift-font/src/interpolation.rs +++ b/crates/shift-font/src/interpolation.rs @@ -2,6 +2,7 @@ use std::collections::{hash_map::Entry, HashMap, HashSet}; use std::str::FromStr; +use std::sync::Arc; use fontdrasil::coords::{NormalizedCoord, NormalizedLocation}; use fontdrasil::types::Tag; @@ -156,8 +157,8 @@ impl GlyphInterpolationSource { /// Reusable interpolation for one glyph's compatible authored source layers. #[derive(Clone, Debug, PartialEq)] pub struct GlyphInterpolation { - reference_layer: GlyphLayer, - basis: InterpolationBasis, + reference_layer: Arc, + basis: Arc, sources: Vec, } @@ -189,7 +190,7 @@ impl GlyphInterpolation { /// support axis, or a glyph-value shape error if the interpolation model /// and its structural reference layer are inconsistent. pub fn resolve(&self, location: &Location, axes: &[Axis]) -> CoreResult { - let mut layer = self.reference_layer.clone(); + let mut layer = self.reference_layer.as_ref().clone(); let values = self.values_at(location, axes)?; layer.apply_interpolation_values(&values)?; Ok(layer) @@ -237,6 +238,14 @@ impl Font { pub fn glyph_interpolation( &self, glyph_id: &GlyphId, + ) -> CoreResult> { + self.glyph_interpolation_with_bases(glyph_id, &mut HashMap::new()) + } + + pub(crate) fn glyph_interpolation_with_bases( + &self, + glyph_id: &GlyphId, + bases: &mut HashMap, Arc>, ) -> CoreResult> { let glyph = self .glyph(glyph_id.clone()) @@ -269,14 +278,25 @@ impl Font { )); } - let Some(basis) = InterpolationBasis::from_source_locations( - &compatible_sources - .iter() - .map(|(source_id, location, _)| (source_id.clone(), location.clone())) - .collect::>(), - self.axes(), - ) else { - return Ok(None); + let source_locations = compatible_sources + .iter() + .map(|(source_id, location, _)| (source_id.clone(), location.clone())) + .collect::>(); + let basis_key = source_locations + .iter() + .map(|(source_id, _)| source_id.clone()) + .collect::>(); + let basis = if let Some(basis) = bases.get(&basis_key) { + basis.clone() + } else { + let Some(basis) = + InterpolationBasis::from_source_locations(&source_locations, self.axes()) + else { + return Ok(None); + }; + let basis = Arc::new(basis); + bases.insert(basis_key, basis.clone()); + basis }; let sources = compatible_sources .into_iter() @@ -284,17 +304,14 @@ impl Font { .collect(); Ok(Some(GlyphInterpolation { - reference_layer: reference_layer.clone(), + reference_layer, basis, sources, })) } } -fn interpolation_reference_layer<'a>( - font: &Font, - glyph: &'a crate::Glyph, -) -> Option<&'a GlyphLayer> { +fn interpolation_reference_layer(font: &Font, glyph: &crate::Glyph) -> Option> { if let Some(default_source_id) = font.default_source_id() { let default_is_master = font .sources() @@ -302,18 +319,28 @@ fn interpolation_reference_layer<'a>( .find(|source| source.id() == default_source_id) .is_some_and(crate::Source::is_master); if default_is_master { - if let Some(layer) = glyph.layer_for_source(default_source_id) { - return Some(layer); + if let Some(layer) = glyph + .layers() + .values() + .find(|layer| layer.source_id() == default_source_id) + { + return Some(layer.clone()); } } } - font.sources() - .iter() - .filter(|source| source.is_master()) - .filter_map(|source| glyph.layer_for_source(source.id())) + glyph + .layers() + .values() + .filter(|layer| { + font.sources() + .iter() + .find(|source| source.id() == layer.source_id()) + .is_some_and(crate::Source::is_master) + }) + .cloned() .reduce(|preferred, candidate| { - if layer_complexity(candidate) > layer_complexity(preferred) { + if layer_complexity(&candidate) > layer_complexity(&preferred) { candidate } else { preferred diff --git a/crates/shift-font/src/ir/font.rs b/crates/shift-font/src/ir/font.rs index 135b09e0..d29813c6 100644 --- a/crates/shift-font/src/ir/font.rs +++ b/crates/shift-font/src/ir/font.rs @@ -1166,15 +1166,12 @@ impl Font { let replacement_ids = self.index().validate_layer_replacements(&replacements)?; let state = self.state_mut(); - for (glyph_id, previous, _) in &replacements { - state.index.remove_layer(glyph_id.clone(), previous); + for (_, previous, _) in &replacements { + for entity_id in glyph_entity_ids(previous) { + state.index.entity_ids.remove(&entity_id); + } } for (glyph_id, _, layer) in replacements { - state.index.layer_owner.insert(layer.id(), glyph_id.clone()); - state - .index - .layer_by_glyph_source - .insert((glyph_id.clone(), layer.source_id()), layer.id()); let glyph = state .data .glyphs @@ -1752,13 +1749,13 @@ mod tests { let first_layer_id = first_layer.id(); let mut first_glyph = Glyph::new("A"); first_glyph.set_layer(first_layer); - font.insert_glyph(first_glyph).unwrap(); + let first_glyph_id = font.insert_glyph(first_glyph).unwrap(); let second_layer = GlyphLayer::with_width(LayerId::new(), source_id.clone(), 600.0); let second_layer_id = second_layer.id(); let mut second_glyph = Glyph::new("B"); second_glyph.set_layer(second_layer); - font.insert_glyph(second_glyph).unwrap(); + let second_glyph_id = font.insert_glyph(second_glyph).unwrap(); let contour_id = ContourId::new(); let mut first_contour = Contour::with_id(contour_id.clone()); @@ -1782,11 +1779,27 @@ mod tests { font.replace_glyph_layers(vec![ GlyphLayer::with_width(first_layer_id.clone(), source_id.clone(), 700.0), - GlyphLayer::with_width(second_layer_id.clone(), source_id, 800.0), + GlyphLayer::with_width(second_layer_id.clone(), source_id.clone(), 800.0), ]) .unwrap(); - assert_eq!(font.layer(first_layer_id).unwrap().width(), 700.0); - assert_eq!(font.layer(second_layer_id).unwrap().width(), 800.0); + assert_eq!(font.layer(first_layer_id.clone()).unwrap().width(), 700.0); + assert_eq!(font.layer(second_layer_id.clone()).unwrap().width(), 800.0); + assert_eq!( + font.glyph_id_by_layer(first_layer_id.clone()), + Some(first_glyph_id.clone()) + ); + assert_eq!( + font.glyph_id_by_layer(second_layer_id.clone()), + Some(second_glyph_id.clone()) + ); + assert_eq!( + font.layer_id_for_glyph_source(first_glyph_id, source_id.clone()), + Some(first_layer_id) + ); + assert_eq!( + font.layer_id_for_glyph_source(second_glyph_id, source_id), + Some(second_layer_id) + ); } #[test] diff --git a/crates/shift-font/src/projection.rs b/crates/shift-font/src/projection.rs index dbb0aeeb..3752d9b7 100644 --- a/crates/shift-font/src/projection.rs +++ b/crates/shift-font/src/projection.rs @@ -1,11 +1,12 @@ //! Location-independent glyph backing and location-bound read-only resolution. use std::collections::HashMap; +use std::sync::Arc; use crate::composite::{resolved_contours_from_layers, GlyphComponents, ResolvedContour}; use crate::{ - Axis, CoreError, CoreResult, Font, Glyph, GlyphId, GlyphInterpolation, GlyphLayer, Location, - Source, SourceId, + Axis, CoreError, CoreResult, Font, Glyph, GlyphId, GlyphInterpolation, GlyphLayer, + InterpolationBasis, Location, Source, SourceId, }; /// One exact-source shape that cannot be represented by compatible variation. @@ -16,7 +17,7 @@ use crate::{ #[derive(Clone, Debug, PartialEq)] pub struct GlyphSourceShape { source_id: SourceId, - layer: GlyphLayer, + layer: Arc, } impl GlyphSourceShape { @@ -35,19 +36,31 @@ impl GlyphSourceShape { /// /// The projection contains a structural fallback, optional compatible interpolation, /// and exact-source exceptions for authored topology the variation cannot -/// reproduce. It owns derived layer clones and never mutates the font. +/// reproduce. It shares immutable authored layers, owns derived interpolation +/// values, and never mutates the font. #[derive(Clone, Debug, PartialEq)] pub struct GlyphProjection { glyph_id: GlyphId, - layers: GlyphLayerProjection, + layers: Arc, components: GlyphComponents, exact_source_components: Vec, component_glyph_ids: Vec, } +/// Immutable projections prepared for requested roots and their component closure. +/// +/// The set owns derived interpolation values but shares authored layers. It is +/// valid only for the font revision from which it was built; callers should +/// discard it after their read or compilation instead of retaining it across edits. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GlyphProjectionSet { + projections: HashMap>, + interpolation_basis_count: usize, +} + #[derive(Clone, Debug, PartialEq)] struct GlyphLayerProjection { - fallback: GlyphLayer, + fallback: Arc, interpolation: Option, exact_source_shapes: Vec, } @@ -62,7 +75,7 @@ impl GlyphLayerProjection { let exact_source_id = exact_source_id(location, axes, sources); if let Some(source_id) = exact_source_id { if source_id == self.fallback.source_id() { - return Ok(self.fallback.clone()); + return Ok(self.fallback.as_ref().clone()); } if let Some(source_shape) = self @@ -70,12 +83,12 @@ impl GlyphLayerProjection { .iter() .find(|source_shape| source_shape.source_id == source_id) { - return Ok(source_shape.layer.clone()); + return Ok(source_shape.layer.as_ref().clone()); } } let Some(interpolation) = &self.interpolation else { - return Ok(self.fallback.clone()); + return Ok(self.fallback.as_ref().clone()); }; interpolation.resolve(location, axes) @@ -166,6 +179,7 @@ pub struct FontProjection<'a> { font: &'a Font, location: Location, layers: HashMap, + interpolation_bases: HashMap, Arc>, } /// Fully resolved glyph geometry at one internal authoring location. @@ -198,19 +212,20 @@ impl ResolvedGlyph { } impl Font { - fn glyph_layer_projection( + fn glyph_layer_projection_with_bases( &self, glyph_id: &GlyphId, - ) -> CoreResult> { + interpolation_bases: &mut HashMap, Arc>, + ) -> CoreResult>> { let Some(glyph) = self.glyph(glyph_id.clone()) else { return Ok(None); }; - let interpolation = self.glyph_interpolation(glyph_id)?; + let interpolation = self.glyph_interpolation_with_bases(glyph_id, interpolation_bases)?; let fallback = interpolation .as_ref() - .map(GlyphInterpolation::reference_layer) - .or_else(|| fallback_layer(self, glyph)) - .cloned(); + .and_then(|interpolation| glyph.layers().get(&interpolation.reference_layer().id())) + .cloned() + .or_else(|| fallback_layer(self, glyph)); let Some(fallback) = fallback else { return Ok(None); }; @@ -219,7 +234,11 @@ impl Font { .iter() .filter(|source| source.is_master()) .filter_map(|source| { - let layer = glyph.layer_for_source(source.id())?; + let layer = glyph + .layers() + .values() + .find(|layer| layer.source_id() == source.id())? + .clone(); if layer.id() == fallback.id() { return None; } @@ -234,24 +253,27 @@ impl Font { Some(GlyphSourceShape { source_id: source.id(), - layer: layer.clone(), + layer, }) }) .collect(); - Ok(Some(GlyphLayerProjection { + Ok(Some(Arc::new(GlyphLayerProjection { fallback, interpolation, exact_source_shapes, - })) + }))) } - fn resolved_layer_at( + fn resolved_layer_at_with_bases( &self, glyph_id: &GlyphId, location: &Location, + interpolation_bases: &mut HashMap, Arc>, ) -> CoreResult> { - let Some(projection) = self.glyph_layer_projection(glyph_id)? else { + let Some(projection) = + self.glyph_layer_projection_with_bases(glyph_id, interpolation_bases)? + else { return Ok(None); }; @@ -260,30 +282,29 @@ impl Font { .map(Some) } - /// Builds layer projections for every glyph reachable from the root's - /// component references, across the fallback and all exact-source shapes. - /// - /// The union walk covers the component closure of every master, so later - /// per-source structural walks never miss a glyph. Missing or shapeless - /// glyphs are recorded as `None` and surface as unresolvable-component - /// errors when a structural walk actually reaches them. - fn collect_component_layer_projections( + fn collect_glyph_layer_projections( &self, - root_projection: &GlyphLayerProjection, - projections: &mut HashMap>, + glyph_ids: &[GlyphId], + projections: &mut HashMap>>, + projection_order: &mut Vec, + interpolation_bases: &mut HashMap, Arc>, ) -> CoreResult<()> { - let mut pending: Vec = component_base_glyph_ids(root_projection).collect(); + let mut pending = glyph_ids.iter().rev().cloned().collect::>(); while let Some(glyph_id) = pending.pop() { if projections.contains_key(&glyph_id) { continue; } - let projection = self.glyph_layer_projection(&glyph_id)?; - if let Some(projection) = &projection { - pending.extend(component_base_glyph_ids(projection)); - } - projections.insert(glyph_id, projection); + let projection = + self.glyph_layer_projection_with_bases(&glyph_id, interpolation_bases)?; + let component_glyph_ids = projection + .as_ref() + .map(|projection| component_base_glyph_ids(projection).collect::>()) + .unwrap_or_default(); + projections.insert(glyph_id.clone(), projection); + projection_order.push(glyph_id); + pending.extend(component_glyph_ids.into_iter().rev()); } Ok(()) @@ -299,7 +320,7 @@ impl Font { &self, root_glyph_id: &GlyphId, root_projection: &GlyphLayerProjection, - projections: &HashMap>, + projections: &HashMap>>, source_id: &SourceId, ) -> CoreResult { let root_layer = structural_layer(root_projection, source_id); @@ -336,43 +357,21 @@ impl Font { GlyphComponents::from_layers(root_glyph_id, &layers) } - /// Builds a compact, location-independent projection for one glyph. - /// - /// Compatible master layers become one interpolation. Exact authored - /// layers with incompatible topology are retained as exact source shapes so - /// their shapes remain visible at their own source locations. A static or - /// otherwise nonviable glyph retains its non-fallback source layers as - /// exact-source shapes. - /// - /// # Errors - /// - /// Returns [`crate::CoreError::GlyphNotFound`] when `glyph_id` is absent, - /// [`crate::CoreError::UnresolvableComponentGlyph`] when a component - /// references a glyph without a master-backed projection, or an - /// interpolation construction error from the glyph's variation data. - pub fn glyph_projection(&self, glyph_id: &GlyphId) -> CoreResult> { - if self.glyph(glyph_id.clone()).is_none() { - return Err(CoreError::GlyphNotFound(glyph_id.clone())); - } - let Some(layers) = self.glyph_layer_projection(glyph_id)? else { - return Ok(None); - }; - - let mut component_projections = HashMap::new(); - self.collect_component_layer_projections(&layers, &mut component_projections)?; + fn glyph_projection_from_layers( + &self, + glyph_id: &GlyphId, + layers: Arc, + projections: &HashMap>>, + ) -> CoreResult { let fallback_source_id = layers.fallback.source_id(); - let components = self.structural_components( - glyph_id, - &layers, - &component_projections, - &fallback_source_id, - )?; + let components = + self.structural_components(glyph_id, &layers, projections, &fallback_source_id)?; let mut exact_source_components = Vec::new(); for source in self.sources().iter().filter(|source| source.is_master()) { let source_id = source.id(); let source_components = - self.structural_components(glyph_id, &layers, &component_projections, &source_id)?; + self.structural_components(glyph_id, &layers, projections, &source_id)?; if source_components != components { exact_source_components.push(GlyphSourceComponents { source_id, @@ -394,13 +393,73 @@ impl Font { component_glyph_ids.sort(); component_glyph_ids.dedup(); - Ok(Some(GlyphProjection { + Ok(GlyphProjection { glyph_id: glyph_id.clone(), layers, components, exact_source_components, component_glyph_ids, - })) + }) + } + + /// Prepares each requested glyph projection and its transitive component closure once. + /// + /// Interpolation bases are shared when the ordered compatible source identities match. + /// The result owns no mutable font state and must be discarded after the current read or + /// compilation so projections cannot outlive authored edits. + pub fn glyph_projection_set(&self, glyph_ids: &[GlyphId]) -> CoreResult { + for glyph_id in glyph_ids { + if self.glyph(glyph_id.clone()).is_none() { + return Err(CoreError::GlyphNotFound(glyph_id.clone())); + } + } + + let mut layer_projections = HashMap::new(); + let mut projection_order = Vec::new(); + let mut interpolation_bases = HashMap::new(); + self.collect_glyph_layer_projections( + glyph_ids, + &mut layer_projections, + &mut projection_order, + &mut interpolation_bases, + )?; + + let mut projections = HashMap::with_capacity(layer_projections.len()); + for glyph_id in projection_order { + let projection = layer_projections + .get(&glyph_id) + .cloned() + .flatten() + .map(|layers| { + self.glyph_projection_from_layers(&glyph_id, layers, &layer_projections) + }) + .transpose()?; + projections.insert(glyph_id, projection); + } + + Ok(GlyphProjectionSet { + projections, + interpolation_basis_count: interpolation_bases.len(), + }) + } + + /// Builds a compact, location-independent projection for one glyph. + /// + /// Compatible master layers become one interpolation. Exact authored + /// layers with incompatible topology are retained as exact source shapes so + /// their shapes remain visible at their own source locations. A static or + /// otherwise nonviable glyph retains its non-fallback source layers as + /// exact-source shapes. + /// + /// # Errors + /// + /// Returns [`crate::CoreError::GlyphNotFound`] when `glyph_id` is absent, + /// [`crate::CoreError::UnresolvableComponentGlyph`] when a component + /// references a glyph without a master-backed projection, or an + /// interpolation construction error from the glyph's variation data. + pub fn glyph_projection(&self, glyph_id: &GlyphId) -> CoreResult> { + let mut projections = self.glyph_projection_set(std::slice::from_ref(glyph_id))?; + Ok(projections.projections.remove(glyph_id).flatten()) } /// Creates a read-only projection at an internal authoring location. @@ -412,7 +471,88 @@ impl Font { font: self, location: location.clone(), layers: HashMap::new(), + interpolation_bases: HashMap::new(), + } + } +} + +impl GlyphProjectionSet { + /// Returns the prepared projection for a glyph, or `None` for a layerless glyph. + pub fn projection(&self, glyph_id: &GlyphId) -> Option<&GlyphProjection> { + self.projections.get(glyph_id).and_then(Option::as_ref) + } + + /// Number of requested and transitively referenced glyph identities represented. + pub fn glyph_count(&self) -> usize { + self.projections.len() + } + + /// Number of distinct compatible source-location sets built for these glyphs. + pub fn interpolation_basis_count(&self) -> usize { + self.interpolation_basis_count + } + + /// Resolves one prepared glyph without rebuilding its projection or component closure. + pub fn resolve_glyph( + &self, + glyph_id: &GlyphId, + location: &Location, + axes: &[Axis], + sources: &[Source], + ) -> CoreResult> { + let mut layers = HashMap::new(); + if !self.prepare_layer_tree(glyph_id, location, axes, sources, &mut layers)? { + return Ok(None); } + let layer = layers + .get(glyph_id) + .expect("prepared glyph layers contain the requested root"); + + Ok(Some(ResolvedGlyph { + glyph_id: glyph_id.clone(), + contours: resolved_contours_from_layers(glyph_id, &layers)?, + x_advance: layer.width(), + })) + } + + fn prepare_layer_tree( + &self, + glyph_id: &GlyphId, + location: &Location, + axes: &[Axis], + sources: &[Source], + layers: &mut HashMap, + ) -> CoreResult { + if layers.contains_key(glyph_id) { + return Ok(true); + } + + let projection = self + .projections + .get(glyph_id) + .ok_or_else(|| CoreError::GlyphNotFound(glyph_id.clone()))?; + let Some(projection) = projection else { + return Ok(false); + }; + let layer = projection.resolve(location, axes, sources)?; + let components = layer + .components_iter() + .map(|component| (component.id(), component.base_glyph_id())) + .collect::>(); + layers.insert(glyph_id.clone(), layer); + + for (component_id, base_glyph_id) in components { + if self.prepare_layer_tree(&base_glyph_id, location, axes, sources, layers)? { + continue; + } + + return Err(CoreError::UnresolvableComponentGlyph { + component_id, + base_glyph_id, + }); + } + + Ok(true) } } @@ -473,7 +613,12 @@ impl FontProjection<'_> { return Ok(true); } - let Some(layer) = self.font.resolved_layer_at(glyph_id, &self.location)? else { + let Some(layer) = self.font.resolved_layer_at_with_bases( + glyph_id, + &self.location, + &mut self.interpolation_bases, + )? + else { return Ok(false); }; let components = layer @@ -530,12 +675,16 @@ fn component_base_glyph_ids( }) } -fn fallback_layer<'a>(font: &Font, glyph: &'a Glyph) -> Option<&'a GlyphLayer> { +fn fallback_layer(font: &Font, glyph: &Glyph) -> Option> { if let Some(default_source_id) = font.default_source_id() { let is_master = source_for_id(font, &default_source_id).is_some_and(Source::is_master); if is_master { - if let Some(layer) = glyph.layer_for_source(default_source_id) { - return Some(layer); + if let Some(layer) = glyph + .layers() + .values() + .find(|layer| layer.source_id() == default_source_id) + { + return Some(layer.clone()); } } } @@ -545,7 +694,7 @@ fn fallback_layer<'a>(font: &Font, glyph: &'a Glyph) -> Option<&'a GlyphLayer> { .values() .filter(|layer| source_for_id(font, &layer.source_id()).is_some_and(Source::is_master)) .max_by_key(|layer| layer.contours().len() + layer.components().len()) - .map(|layer| layer.as_ref()) + .cloned() } fn source_for_id<'a>(font: &'a Font, source_id: &SourceId) -> Option<&'a Source> { @@ -609,6 +758,73 @@ mod tests { layer } + #[test] + fn projection_set_reuses_shared_components_and_interpolation_bases() { + let mut font = sample_variable_font(); + let child_id = font.glyph_by_name("A").unwrap().id(); + let source_id = font.default_source_id().unwrap(); + let root_ids = [ + GlyphId::from_raw("shared-root-a"), + GlyphId::from_raw("shared-root-b"), + ]; + + for root_id in &root_ids { + let mut root = Glyph::with_id(root_id.clone(), root_id.to_string()); + let mut layer = GlyphLayer::with_width(LayerId::new(), source_id.clone(), 700.0); + layer.add_component(Component::new(child_id.clone(), "A")); + root.set_layer(layer); + font.insert_glyph(root).unwrap(); + } + + let projection_set = font + .glyph_projection_set(&[ + root_ids[0].clone(), + root_ids[1].clone(), + root_ids[0].clone(), + ]) + .unwrap(); + + let child_projection_set = font + .glyph_projection_set(std::slice::from_ref(&child_id)) + .unwrap(); + assert_eq!(child_projection_set.interpolation_basis_count(), 1); + assert_eq!(projection_set.glyph_count(), 3); + assert_eq!(projection_set.interpolation_basis_count(), 2); + assert!(std::ptr::eq( + projection_set + .projection(&root_ids[0]) + .unwrap() + .interpolation() + .unwrap() + .basis(), + projection_set + .projection(&root_ids[1]) + .unwrap() + .interpolation() + .unwrap() + .basis(), + )); + + let mut location = Location::new(); + location.set(font.axes()[0].id(), 600.0); + let expected = font + .projection(&location) + .glyph(&root_ids[0]) + .unwrap() + .unwrap(); + let actual = projection_set + .resolve_glyph(&root_ids[0], &location, font.axes(), font.sources()) + .unwrap() + .unwrap(); + + assert_eq!(actual.x_advance(), expected.x_advance()); + assert_eq!(actual.contours().len(), expected.contours().len()); + assert_eq!( + actual.contours()[0].points[1].x(), + expected.contours()[0].points[1].x() + ); + } + #[test] fn projection_interpolates_when_an_exact_source_has_no_glyph_layer() { let font = sample_variable_font(); diff --git a/crates/shift-slug/docs/DOCS.md b/crates/shift-slug/docs/DOCS.md index 3500fcaf..1a714fe7 100644 --- a/crates/shift-slug/docs/DOCS.md +++ b/crates/shift-slug/docs/DOCS.md @@ -58,7 +58,23 @@ Each glyph owns `band_count` horizontal ranges followed by `band_count` vertical ## Resident variable execution -`build_authored_atlas()` is the product complete-font residency boundary. It delegates to `build_authored_atlas_page()` with every root so complete residency and local edit patches share one compiler and packed layout. A patch compiles an ordered root-glyph batch plus transitive component geometry, preserves explicit `GlyphId` mapping, and deduplicates interpolation bases across that closure. Layerless root records receive zero-curve/zero-advance descriptors so one incomplete draft cannot disable the rest of the grid. Complete atlases and patches are location-independent; axis movement changes only their shared weight vectors and visible instances. +`build_authored_atlas()` is the product complete-font residency boundary. It delegates to `build_authored_atlas_page()` with every root so complete residency and local edit patches share one compiler and packed layout. Each compilation creates one `GlyphProjectionSet` for its ordered roots and transitive component closure. Weight collection, root addition, component preparation, exact-source discovery, and fallback resolution all read that same immutable set instead of rebuilding projections or variation models. Fallback and exact-source resolved glyphs are retained only within the current root, bounding temporary memory; the complete set is dropped after atlas construction and never survives authored edits. A patch preserves explicit `GlyphId` mapping and excludes unrelated roots. Layerless root records receive zero-curve/zero-advance descriptors so one incomplete draft cannot disable the rest of the grid. Complete atlases and patches are location-independent; axis movement changes only their shared weight vectors and visible instances. + +`build_authored_atlas_profiled()` and its page counterpart return nested phase durations from the same compiler path. The bridge uses these functions for `prepareSlugAtlas`; setting `SHIFT_PROFILE_SLUG_ATLAS=1` prints acquisition, projection preparation, weight-set collection, component preparation, fallback bounds, exact-source preparation, atlas addition, layout, and total native time without changing the NAPI endpoint. + +### Fraunces projection-reuse baseline — 2026-08-01 + +A same-machine release comparison on a 6-core/12-thread Ryzen 5 5500U exercised the Fraunces `.shift` corpus through `Bridge.prepareSlugAtlas(256)`. Both revisions produced 699 roots, 1,290 atlas glyphs, 12,713 curves, 998 components, and 2,190,648 bytes. + +| Revision | Cold prepare | Resumed first prepare | Warm p50 | Warm p95 | +| --- | ---: | ---: | ---: | ---: | +| `47fc8c01` repeated projections | 526.811 ms | 688.788 ms | 544.630 ms | 546.411 ms | +| Compilation-scoped reuse | 97.970 ms | 242.474 ms | 100.640 ms | 101.470 ms | +| Fused layer directory reads + stable ownership indexes | 99.237 ms | 227.760 ms p50 | 101.481 ms | 102.879 ms | + +Warm p50 improved 5.41× (81.5%) with projection reuse. The subsequent acquisition optimization removed the second SQLite directory-fact scan and stopped removing/reinserting unchanged layer ownership indexes. Five clean-resume launches prepared 5,580 layers in 11 batches with a 227.760 ms p50 and 230.235 ms p95 total authored preparation; one representative run spent 127.728 ms acquiring layers and 101.921 ms compiling them. + +Representative warm native phases remain 23–25 ms projection preparation, about 0.5 ms weight-set collection, and 72–73 ms atlas addition. Within atlas addition, component preparation is about 19 ms, fallback bounds about 28.5 ms, and exact-source preparation about 7.3 ms. This exceeds the sub-second target without parallelism; parallel preparation remains unwarranted until a larger corpus demonstrates a new bottleneck. The variable model keeps one base quadratic array plus base-relative `f32` source deltas. Each 8-byte source descriptor remains dense by default; only a source whose unchanged curves make sparse storage strictly smaller receives a tagged offset into a compact side table of sorted glyph-local indexes. Dense fonts therefore pay no sparse metadata tax. Each source references a global weight index so equal interpolation bases share a small per-frame weight vector. The complete packed atlas remains one logical byte stream but may span two `array` storage bindings. A split offset and every logical section offset fit in the existing 64-byte uniform; one accessor selects the physical buffer without changing packed bytes. Typed WGSL decoders preserve the exact little-endian resident layout while using exactly eight storage bindings in the resolve entry point, matching WebGPU's baseline binding-count and 128 MiB storage-binding limits. Compute preserves the full weighted-source equation as `base × sum(weights) + Σ(weight × delta)`, rather than assuming weights always sum to one. A one-bit-per-curve resident mask marks controls generated from authored lines: after endpoint interpolation, compute regenerates those controls with Slug's normalized perpendicular epsilon because that operation is nonlinear and cannot be represented exactly by source control deltas. One workgroup per visible glyph resolves curves and reduces exact current-location bounds into scratch; a second pass rebuilds the eight horizontal and vertical bands using those bounds. Fragment band selection reads the same scratch bounds. Cell sizing remains a consumer-owned metrics/advance transform, so neither loose all-location bounds nor current-location geometry can shrink or jump the grid layout. Offscreen glyphs perform none of this work until visible. diff --git a/crates/shift-slug/src/authored.rs b/crates/shift-slug/src/authored.rs index 54c9c946..d9d8236e 100644 --- a/crates/shift-slug/src/authored.rs +++ b/crates/shift-slug/src/authored.rs @@ -9,10 +9,11 @@ use std::{collections::HashMap, error::Error, fmt}; use shift_font::{ composite::ResolvedContour, ContourId, CoreError, CurveSegment, CurveSegmentIter, Font, - GlyphLayer, GlyphProjection, Point as FontPoint, PointId, PointType, SourceId, + GlyphLayer, GlyphProjection, GlyphProjectionSet, Point as FontPoint, PointId, PointType, + SourceId, }; -use crate::{Curve, Point, SlugError, VariableAtlasBuilder}; +use crate::{AuthoredAtlasProfile, Curve, Point, SlugError, VariableAtlasBuilder}; mod component; pub use component::{add_authored_glyph_with_weight_sets, AuthoredWeightSet}; @@ -20,6 +21,18 @@ pub use component::{add_authored_glyph_with_weight_sets, AuthoredWeightSet}; pub(crate) type AuthoredDefaultKey = (shift_font::GlyphId, Vec, u32); pub(crate) type AuthoredDefaultGlyphs = HashMap; +/// Borrowed inputs for compiling one authored root glyph. +/// +/// The compilation mutates only phase timings. It owns no cache and is dropped +/// after the root; resolved source glyphs remain in a separate root-local map. +pub(super) struct AuthoredGlyphCompilation<'a, 'profile> { + font: &'a Font, + projection_set: &'a GlyphProjectionSet, + weight_sets: &'a [AuthoredWeightSet], + constant_weight_index: u32, + profile: &'profile mut AuthoredAtlasProfile, +} + /// Product semantics that the first authored contour adapter cannot yet encode. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct AuthoredGlyphRequirements { @@ -104,17 +117,63 @@ impl AuthoredAtlasBuilder { projection: &GlyphProjection, weight_sets: &[AuthoredWeightSet], constant_weight_index: u32, + ) -> Result { + let glyph_id = projection.glyph_id(); + let projection_set = font.glyph_projection_set(std::slice::from_ref(&glyph_id))?; + let projection = projection_set + .projection(&glyph_id) + .ok_or_else(|| CoreError::GlyphNotFound(glyph_id.clone()))?; + self.add_glyph_from_projection_set( + font, + &projection_set, + projection, + weight_sets, + constant_weight_index, + ) + } + + pub(crate) fn add_glyph_from_projection_set( + &mut self, + font: &Font, + projection_set: &GlyphProjectionSet, + projection: &GlyphProjection, + weight_sets: &[AuthoredWeightSet], + constant_weight_index: u32, + ) -> Result { + self.add_glyph_from_projection_set_profiled( + font, + projection_set, + projection, + weight_sets, + constant_weight_index, + &mut AuthoredAtlasProfile::default(), + ) + } + + pub(crate) fn add_glyph_from_projection_set_profiled( + &mut self, + font: &Font, + projection_set: &GlyphProjectionSet, + projection: &GlyphProjection, + weight_sets: &[AuthoredWeightSet], + constant_weight_index: u32, + profile: &mut AuthoredAtlasProfile, ) -> Result { let checkpoint = self.builder.checkpoint(); let mut inserted_defaults = Vec::new(); + let mut compilation = AuthoredGlyphCompilation { + font, + projection_set, + weight_sets, + constant_weight_index, + profile, + }; match component::add_authored_glyph_with_weight_sets_cached( &mut self.builder, &mut self.defaults, &mut inserted_defaults, - font, + &mut compilation, projection, - weight_sets, - constant_weight_index, ) { Ok(glyph) => Ok(glyph), Err(error) => { @@ -730,15 +789,28 @@ pub fn add_authored_component_projection_glyph( interpolation.basis().clone(), source_weight_indices.to_vec(), )?; + let glyph_id = projection.glyph_id(); + let projection_set = font.glyph_projection_set(std::slice::from_ref(&glyph_id))?; + let projection = projection_set + .projection(&glyph_id) + .ok_or_else(|| CoreError::GlyphNotFound(glyph_id.clone()))?; let checkpoint = builder.checkpoint(); + let weight_sets = [weight_set]; + let mut profile = AuthoredAtlasProfile::default(); + let mut compilation = AuthoredGlyphCompilation { + font, + projection_set: &projection_set, + weight_sets: &weight_sets, + constant_weight_index: 0, + profile: &mut profile, + }; let glyph_index = component::add_default_component_projection_glyph( builder, &mut AuthoredDefaultGlyphs::new(), &mut Vec::new(), - font, + &mut compilation, projection, - &[weight_set], - 0, + &mut HashMap::new(), ) .map_err(|error| match error { AuthoredSlugError::MissingWeightBasis(_) => AuthoredSlugError::ComponentBasisMismatch, diff --git a/crates/shift-slug/src/authored/component.rs b/crates/shift-slug/src/authored/component.rs index c8ba55b1..40aed8ff 100644 --- a/crates/shift-slug/src/authored/component.rs +++ b/crates/shift-slug/src/authored/component.rs @@ -1,20 +1,21 @@ -use std::collections::HashMap; +use std::collections::{hash_map::Entry, HashMap}; +use std::time::Instant; use shift_font::{ composite::ComponentAnchorReference, ComponentId, Font, GlyphId, GlyphLayer, GlyphProjection, - InterpolationBasis, + GlyphProjectionSet, InterpolationBasis, ResolvedGlyph, SourceId, }; use crate::variable::ROOT_COMPONENT; use crate::{ - Bounds, VariableAnchorSource, VariableAtlasBuilder, VariableComponent, VariableComponentPart, - VariableComponentSource, + AuthoredAtlasProfile, Bounds, VariableAnchorSource, VariableAtlasBuilder, VariableComponent, + VariableComponentPart, VariableComponentSource, }; use super::{ add_default_projection_glyph, authored_advance, curves_from_resolved_contours, source_location, - AuthoredDefaultGlyphs, AuthoredDefaultKey, AuthoredGlyph, AuthoredSlugError, - AuthoredSourceGlyph, ResolvedCurveTopology, + AuthoredDefaultGlyphs, AuthoredDefaultKey, AuthoredGlyph, AuthoredGlyphCompilation, + AuthoredSlugError, AuthoredSourceGlyph, ResolvedCurveTopology, }; /// One deduplicated interpolation basis and its per-frame weight-buffer indexes. @@ -60,17 +61,28 @@ pub fn add_authored_glyph_with_weight_sets( weight_sets: &[AuthoredWeightSet], constant_weight_index: u32, ) -> Result { + let glyph_id = projection.glyph_id(); + let projection_set = font.glyph_projection_set(std::slice::from_ref(&glyph_id))?; + let projection = projection_set + .projection(&glyph_id) + .ok_or_else(|| shift_font::CoreError::GlyphNotFound(glyph_id.clone()))?; let checkpoint = builder.checkpoint(); let mut defaults = AuthoredDefaultGlyphs::new(); let mut inserted_defaults = Vec::new(); + let mut profile = AuthoredAtlasProfile::default(); + let mut compilation = AuthoredGlyphCompilation { + font, + projection_set: &projection_set, + weight_sets, + constant_weight_index, + profile: &mut profile, + }; match add_authored_glyph_with_weight_sets_cached( builder, &mut defaults, &mut inserted_defaults, - font, + &mut compilation, projection, - weight_sets, - constant_weight_index, ) { Ok(glyph) => Ok(glyph), Err(error) => { @@ -84,47 +96,49 @@ pub(super) fn add_authored_glyph_with_weight_sets_cached( builder: &mut VariableAtlasBuilder, defaults: &mut AuthoredDefaultGlyphs, inserted_defaults: &mut Vec, - font: &Font, + compilation: &mut AuthoredGlyphCompilation<'_, '_>, projection: &GlyphProjection, - weight_sets: &[AuthoredWeightSet], - constant_weight_index: u32, ) -> Result { + let mut resolved_sources = HashMap::new(); let default_glyph = if projection.components().components().is_empty() { - let source_weight_indices = projection_weight_indices(projection, weight_sets)?; + let source_weight_indices = projection_weight_indices(projection, compilation.weight_sets)?; add_default_projection_glyph( builder, defaults, inserted_defaults, projection, source_weight_indices, - constant_weight_index, + compilation.constant_weight_index, )? } else { add_default_component_projection_glyph( builder, defaults, inserted_defaults, - font, + compilation, projection, - weight_sets, - constant_weight_index, + &mut resolved_sources, )? }; - let exact_source_ids = exact_source_ids(font, projection)?; + let exact_started = Instant::now(); + let exact_source_ids = + exact_source_ids(compilation.font, compilation.projection_set, projection)?; let mut exact_sources = Vec::with_capacity(exact_source_ids.len()); for source_id in exact_source_ids { - let location = source_location(font, &source_id)?; - let mut font_projection = font.projection(location); - let resolved = font_projection - .glyph(&projection.glyph_id())? - .ok_or_else(|| shift_font::CoreError::GlyphNotFound(projection.glyph_id()))?; + let resolved = resolved_source_glyph( + &mut resolved_sources, + compilation.font, + compilation.projection_set, + projection, + &source_id, + )?; let topology = ResolvedCurveTopology::from_contours(resolved.contours()); let advance = authored_advance(resolved.x_advance(), 0)?; let glyph_index = builder.add_curve_glyph_with_sources_and_lines( topology.curves_from_contours(resolved.contours())?, topology.line_flags(), - constant_weight_index, + compilation.constant_weight_index, [], )?; builder.set_glyph_source_advances(glyph_index, [advance])?; @@ -133,6 +147,7 @@ pub(super) fn add_authored_glyph_with_weight_sets_cached( glyph_index, }); } + compilation.profile.exact_source_preparation += exact_started.elapsed(); Ok(AuthoredGlyph { default_glyph, @@ -144,12 +159,11 @@ pub(super) fn add_default_component_projection_glyph( builder: &mut VariableAtlasBuilder, defaults: &mut AuthoredDefaultGlyphs, inserted_defaults: &mut Vec, - font: &Font, + compilation: &mut AuthoredGlyphCompilation<'_, '_>, projection: &GlyphProjection, - weight_sets: &[AuthoredWeightSet], - constant_weight_index: u32, + resolved_sources: &mut HashMap, ) -> Result { - let projections = component_projections(font, projection)?; + let component_started = Instant::now(); let mut direct_glyphs = HashMap::::new(); for glyph_id in std::iter::once(projection.glyph_id()).chain( projection @@ -161,15 +175,15 @@ pub(super) fn add_default_component_projection_glyph( if direct_glyphs.contains_key(&glyph_id) { continue; } - let direct_projection = projection_for(&projections, &glyph_id)?; - let weight_indices = projection_weight_indices(direct_projection, weight_sets)?; + let direct_projection = projection_for(compilation.projection_set, &glyph_id)?; + let weight_indices = projection_weight_indices(direct_projection, compilation.weight_sets)?; let glyph_index = add_default_projection_glyph( builder, defaults, inserted_defaults, direct_projection, weight_indices, - constant_weight_index, + compilation.constant_weight_index, )?; direct_glyphs.insert(glyph_id, glyph_index); } @@ -186,12 +200,13 @@ pub(super) fn add_default_component_projection_glyph( let mut anchor_sources = Vec::new(); let mut components = Vec::with_capacity(projection.components().components().len()); for (component_index, occurrence) in projection.components().components().iter().enumerate() { - let parent_projection = projection_for(&projections, &occurrence.parent_glyph_id())?; + let parent_projection = + projection_for(compilation.projection_set, &occurrence.parent_glyph_id())?; let source_start = u32::try_from(component_sources.len()).map_err(|_| crate::SlugError::LengthOverflow)?; let source_context = AuthoredComponentSourceContext { - weight_sets, - constant_weight_index, + weight_sets: compilation.weight_sets, + constant_weight_index: compilation.constant_weight_index, component_index, }; append_component_sources( @@ -221,14 +236,14 @@ pub(super) fn add_default_component_projection_glyph( if let Some(attachment) = occurrence.attachment() { let source_range = append_anchor_sources( &mut anchor_sources, - projection_for(&projections, &attachment.source().glyph_id())?, + projection_for(compilation.projection_set, &attachment.source().glyph_id())?, attachment.source(), &source_context, "source anchor", )?; let target_range = append_anchor_sources( &mut anchor_sources, - projection_for(&projections, &attachment.target().glyph_id())?, + projection_for(compilation.projection_set, &attachment.target().glyph_id())?, attachment.target(), &source_context, "target anchor", @@ -268,7 +283,15 @@ pub(super) fn add_default_component_projection_glyph( )?; } - let bounds = fallback_bounds(font, projection)?; + compilation.profile.component_preparation += component_started.elapsed(); + let fallback_started = Instant::now(); + let bounds = fallback_bounds( + compilation.font, + compilation.projection_set, + projection, + resolved_sources, + )?; + compilation.profile.fallback_bounds += fallback_started.elapsed(); builder .add_component_glyph( bounds, @@ -281,26 +304,12 @@ pub(super) fn add_default_component_projection_glyph( .map_err(Into::into) } -fn component_projections( - font: &Font, - root: &GlyphProjection, -) -> Result, AuthoredSlugError> { - let mut projections = HashMap::from([(root.glyph_id(), root.clone())]); - for glyph_id in root.component_glyph_ids() { - let projection = font - .glyph_projection(glyph_id)? - .ok_or_else(|| shift_font::CoreError::GlyphNotFound(glyph_id.clone()))?; - projections.insert(glyph_id.clone(), projection); - } - Ok(projections) -} - fn projection_for<'a>( - projections: &'a HashMap, + projection_set: &'a GlyphProjectionSet, glyph_id: &GlyphId, ) -> Result<&'a GlyphProjection, AuthoredSlugError> { - projections - .get(glyph_id) + projection_set + .projection(glyph_id) .ok_or_else(|| shift_font::CoreError::GlyphNotFound(glyph_id.clone()).into()) } @@ -467,12 +476,19 @@ fn append_part( Ok(()) } -fn fallback_bounds(font: &Font, projection: &GlyphProjection) -> Result { - let location = source_location(font, &projection.fallback().source_id())?; - let mut font_projection = font.projection(location); - let resolved = font_projection - .glyph(&projection.glyph_id())? - .ok_or_else(|| shift_font::CoreError::GlyphNotFound(projection.glyph_id()))?; +fn fallback_bounds( + font: &Font, + projection_set: &GlyphProjectionSet, + projection: &GlyphProjection, + resolved_sources: &mut HashMap, +) -> Result { + let resolved = resolved_source_glyph( + resolved_sources, + font, + projection_set, + projection, + &projection.fallback().source_id(), + )?; let curves = curves_from_resolved_contours(resolved.contours())?; let mut curves = curves.into_iter(); let Some(first) = curves.next() else { @@ -491,15 +507,23 @@ fn fallback_bounds(font: &Font, projection: &GlyphProjection) -> Result Result, AuthoredSlugError> { - let projections = component_projections(font, root)?; +) -> Result, AuthoredSlugError> { + let projections = std::iter::once(root) + .chain( + root.component_glyph_ids() + .iter() + .map(|glyph_id| projection_for(projection_set, glyph_id)) + .collect::, _>>()?, + ) + .collect::>(); Ok(font .sources() .iter() .map(shift_font::Source::id) .filter(|source_id| { - projections.values().any(|projection| { + projections.iter().any(|projection| { projection .exact_source_shapes() .iter() @@ -512,3 +536,27 @@ fn exact_source_ids( }) .collect()) } + +fn resolved_source_glyph<'a>( + resolved_sources: &'a mut HashMap, + font: &Font, + projection_set: &GlyphProjectionSet, + projection: &GlyphProjection, + source_id: &SourceId, +) -> Result<&'a ResolvedGlyph, AuthoredSlugError> { + match resolved_sources.entry(source_id.clone()) { + Entry::Occupied(entry) => Ok(entry.into_mut()), + Entry::Vacant(entry) => { + let location = source_location(font, source_id)?; + let resolved = projection_set + .resolve_glyph( + &projection.glyph_id(), + location, + font.axes(), + font.sources(), + )? + .ok_or_else(|| shift_font::CoreError::GlyphNotFound(projection.glyph_id()))?; + Ok(entry.insert(resolved)) + } + } +} diff --git a/crates/shift-slug/src/lib.rs b/crates/shift-slug/src/lib.rs index 1b4ad59c..90893dcc 100644 --- a/crates/shift-slug/src/lib.rs +++ b/crates/shift-slug/src/lib.rs @@ -32,8 +32,9 @@ pub use render::{ RENDER_PARAMS_BYTES, }; pub use resident::{ - build_authored_atlas, build_authored_atlas_page, AuthoredAtlas, AuthoredAtlasGlyph, - AuthoredAtlasPage, + build_authored_atlas, build_authored_atlas_page, build_authored_atlas_page_profiled, + build_authored_atlas_profiled, AuthoredAtlas, AuthoredAtlasGlyph, AuthoredAtlasPage, + AuthoredAtlasProfile, }; pub use variable::{ pack_variable_params, PackedVariableAtlas, PackedVariableChunk, PackedVariableChunks, diff --git a/crates/shift-slug/src/resident.rs b/crates/shift-slug/src/resident.rs index 5590fba5..0f45d643 100644 --- a/crates/shift-slug/src/resident.rs +++ b/crates/shift-slug/src/resident.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; +use std::time::{Duration, Instant}; -use shift_font::{CoreError, Font, GlyphId, GlyphProjection}; +use shift_font::{CoreError, Font, GlyphId, GlyphProjection, GlyphProjectionSet}; use crate::{ AuthoredAtlasBuilder, AuthoredGlyph, AuthoredSlugError, AuthoredWeightSet, SlugError, @@ -52,13 +53,35 @@ impl AuthoredAtlasPage { /// A complete-font atlas is one page containing every authored root glyph. pub type AuthoredAtlas = AuthoredAtlasPage; +/// Release-profile phase durations for one authored atlas compilation. +/// +/// Component, fallback-bounds, and exact-source durations are subsets of +/// `atlas_addition`; they identify nested work rather than additive phases. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct AuthoredAtlasProfile { + pub projection_preparation: Duration, + pub weight_set_collection: Duration, + pub component_preparation: Duration, + pub fallback_bounds: Duration, + pub exact_source_preparation: Duration, + pub atlas_addition: Duration, +} + /// Compiles every authored font glyph into one resident Slug generation. pub fn build_authored_atlas( font: &Font, band_count: u32, ) -> Result { + build_authored_atlas_profiled(font, band_count).map(|(atlas, _)| atlas) +} + +/// Compiles every glyph and reports CPU phase durations from the same product path. +pub fn build_authored_atlas_profiled( + font: &Font, + band_count: u32, +) -> Result<(AuthoredAtlas, AuthoredAtlasProfile), AuthoredSlugError> { let glyph_ids = font.glyphs().map(|glyph| glyph.id()).collect::>(); - build_authored_atlas_page(font, &glyph_ids, band_count) + build_authored_atlas_page_profiled(font, &glyph_ids, band_count) } /// Compiles an ordered root-glyph batch and its transitive component geometry. @@ -71,25 +94,54 @@ pub fn build_authored_atlas_page( glyph_ids: &[GlyphId], band_count: u32, ) -> Result { + build_authored_atlas_page_profiled(font, glyph_ids, band_count).map(|(atlas, _)| atlas) +} + +/// Compiles an ordered root batch and reports CPU phase durations from the same product path. +pub fn build_authored_atlas_page_profiled( + font: &Font, + glyph_ids: &[GlyphId], + band_count: u32, +) -> Result<(AuthoredAtlasPage, AuthoredAtlasProfile), AuthoredSlugError> { let glyph_ids = unique_glyph_ids(font, glyph_ids)?; - let (weight_sets, weight_count) = collect_weight_sets(font, &glyph_ids)?; + let mut profile = AuthoredAtlasProfile::default(); + + let started = Instant::now(); + let projection_set = font.glyph_projection_set(&glyph_ids)?; + profile.projection_preparation = started.elapsed(); + + let started = Instant::now(); + let (weight_sets, weight_count) = collect_weight_sets(&projection_set, &glyph_ids)?; + profile.weight_set_collection = started.elapsed(); + let mut builder = AuthoredAtlasBuilder::new(band_count)?; let mut glyphs = Vec::with_capacity(glyph_ids.len()); - + let started = Instant::now(); for glyph_id in glyph_ids { - let authored = match font.glyph_projection(&glyph_id)? { - Some(projection) => builder.add_glyph(font, &projection, &weight_sets, 0)?, + let authored = match projection_set.projection(&glyph_id) { + Some(projection) => builder.add_glyph_from_projection_set_profiled( + font, + &projection_set, + projection, + &weight_sets, + 0, + &mut profile, + )?, None => builder.add_empty_glyph(0)?, }; glyphs.push(AuthoredAtlasGlyph { glyph_id, authored }); } - - Ok(AuthoredAtlasPage { - atlas: builder.finish(), - glyphs, - weight_sets, - weight_count, - }) + profile.atlas_addition = started.elapsed(); + + Ok(( + AuthoredAtlasPage { + atlas: builder.finish(), + glyphs, + weight_sets, + weight_count, + }, + profile, + )) } fn unique_glyph_ids(font: &Font, glyph_ids: &[GlyphId]) -> Result, AuthoredSlugError> { @@ -109,17 +161,17 @@ fn unique_glyph_ids(font: &Font, glyph_ids: &[GlyphId]) -> Result, } fn collect_weight_sets( - font: &Font, + projection_set: &GlyphProjectionSet, glyph_ids: &[GlyphId], ) -> Result<(Vec, u32), AuthoredSlugError> { let mut projections = Vec::new(); let mut seen = HashSet::new(); for glyph_id in glyph_ids { - let Some(root) = font.glyph_projection(glyph_id)? else { + let Some(root) = projection_set.projection(glyph_id) else { continue; }; - collect_projection(font, root, &mut seen, &mut projections)?; + collect_projection(projection_set, root, &mut seen, &mut projections)?; } let mut sets = Vec::new(); @@ -150,23 +202,22 @@ fn collect_weight_sets( Ok((sets, next_weight_index)) } -fn collect_projection( - font: &Font, - root: GlyphProjection, +fn collect_projection<'a>( + projection_set: &'a GlyphProjectionSet, + root: &'a GlyphProjection, seen: &mut HashSet, - projections: &mut Vec, + projections: &mut Vec<&'a GlyphProjection>, ) -> Result<(), AuthoredSlugError> { if !seen.insert(root.glyph_id()) { return Ok(()); } - let component_glyph_ids = root.component_glyph_ids().to_vec(); projections.push(root); - for glyph_id in component_glyph_ids { - let projection = font - .glyph_projection(&glyph_id)? + for glyph_id in root.component_glyph_ids() { + let projection = projection_set + .projection(glyph_id) .ok_or_else(|| CoreError::GlyphNotFound(glyph_id.clone()))?; - collect_projection(font, projection, seen, projections)?; + collect_projection(projection_set, projection, seen, projections)?; } Ok(()) diff --git a/crates/shift-slug/tests/authored_model.rs b/crates/shift-slug/tests/authored_model.rs index 4968da43..ebcc71df 100644 --- a/crates/shift-slug/tests/authored_model.rs +++ b/crates/shift-slug/tests/authored_model.rs @@ -119,8 +119,12 @@ fn complete_authored_atlas_keeps_font_identity_and_independent_bases() { ); let font = FontLoader::new().read_font(&path).unwrap(); + let glyph_ids = font.glyphs().map(Glyph::id).collect::>(); + let projection_set = font.glyph_projection_set(&glyph_ids).unwrap(); let resident = build_authored_atlas(&font, 8).unwrap(); + assert_eq!(projection_set.glyph_count(), font.glyphs().count()); + assert_eq!(projection_set.interpolation_basis_count(), 4); assert_eq!(resident.glyphs().len(), font.glyphs().count()); assert_eq!(resident.weight_sets().len(), 4); assert_eq!(resident.weight_count(), 21); @@ -214,7 +218,9 @@ fn mutatorsans_designspace_matches_authored_projection_at_random_locations() { records.push((glyph.id(), atlas_index, weight_indices, component_glyph)); } let atlas = builder.finish(); + let shared = build_authored_atlas(&font, 8).unwrap(); + assert_eq!(shared.atlas(), &atlas); assert_eq!(records.len(), 49); assert_eq!(component_glyphs, 10); assert_eq!(component_occurrences, 20); diff --git a/crates/shift-store/README.md b/crates/shift-store/README.md index 584b94f8..fc26278e 100644 --- a/crates/shift-store/README.md +++ b/crates/shift-store/README.md @@ -12,7 +12,7 @@ Callers use typed APIs from this crate rather than preparing SQL or opening a se - Points, contours, anchors, transforms, guidelines, and layer lib values are canonical only inside the layer BLOB; they are not duplicated as normalized SQL rows. - Payload replacement, directory facts, component rows, and workspace revision state commit in one transaction. -`ShiftStore::load_font_directory` never reads payload BLOBs. `load_glyph_layer` bounds stored and decoded lengths before fetching and decompressing one payload. `load_glyph_layers` accepts a complete requested set, deduplicates it, and partitions it through the single count- and decoded-byte-aware planner. Each internal batch performs three ordered directory/payload/reference scans for at most 512 layers and 256 MiB decoded bytes, then decompresses and validates with Rayon. Every decoded payload must match its exact declared length and 32-byte BLAKE3 before strict MessagePack decoding. Full-font materialization and workspace acquisition use that same planner; no caller maintains a weaker count-only loop. All payload paths cross-check canonical bytes against relational facts. +`ShiftStore::load_font_directory` never reads payload BLOBs. `load_glyph_layer` bounds stored and decoded lengths before fetching and decompressing one payload. `load_glyph_layers` accepts a complete requested set, deduplicates it, and partitions it through the single count- and decoded-byte-aware planner. The planner reads and validates each layer's complete directory facts once, partitions those facts into batches of at most 512 layers and 256 MiB decoded bytes, and reuses them for the payload and component-reference scans. It then decompresses and validates with Rayon. Every decoded payload must match its exact declared length and 32-byte BLAKE3 before strict MessagePack decoding. Full-font materialization and workspace acquisition use that same planner; no caller maintains a weaker count-only loop. All payload paths cross-check canonical bytes against relational facts. ## Outer payload contract diff --git a/crates/shift-store/src/layer/load.rs b/crates/shift-store/src/layer/load.rs index f18f5f01..70ea438f 100644 --- a/crates/shift-store/src/layer/load.rs +++ b/crates/shift-store/src/layer/load.rs @@ -17,6 +17,11 @@ pub const MAX_LAYER_READ_BATCH_COUNT: usize = 512; pub const MAX_LAYER_READ_BATCH_DECODED_BYTES: u64 = 256 * 1024 * 1024; const MAX_LAYER_PAYLOAD_BYTES_SQL: i64 = MAX_LAYER_PAYLOAD_BYTES as i64; +struct LayerDirectoryRead { + facts: HashMap, + decoded_byte_lengths: Vec, +} + impl ShiftStore { /// Fetches, bounds-checks, decodes, and cross-checks one canonical layer. pub fn load_glyph_layer( @@ -114,50 +119,22 @@ pub(crate) fn load_glyph_layers_from_conn( let mut layers = Vec::with_capacity(keys.len()); for count_batch in keys.chunks(MAX_LAYER_READ_BATCH_COUNT) { - let placeholders = (0..count_batch.len()) - .map(|_| "?") - .collect::>() - .join(","); - let sql = format!( - "SELECT l.id, p.decoded_byte_length - FROM glyph_layers AS l - JOIN glyph_layer_payloads AS p ON p.layer_id = l.id - WHERE l.id IN ({placeholders}) - ORDER BY l.id" - ); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map(rusqlite::params_from_iter(count_batch.iter()), |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) - })?; - let mut decoded_lengths = HashMap::with_capacity(count_batch.len()); - for row in rows { - let (layer_id, decoded_byte_length) = row?; - if !(0..=MAX_LAYER_PAYLOAD_BYTES_SQL).contains(&decoded_byte_length) { - return Err(StoreError::LayerPayloadTooLarge { - bytes: u64::try_from(decoded_byte_length).unwrap_or(u64::MAX), - limit: MAX_LAYER_PAYLOAD_BYTES as u64, - }); - } - decoded_lengths.insert(layer_id, decoded_byte_length as u64); - } - drop(stmt); - + let directory = read_layer_directory(conn, count_batch)?; let mut batch = Vec::new(); let mut batch_decoded_bytes = 0_u64; - for key in count_batch { - let decoded_byte_length = - decoded_lengths - .get(key) - .copied() - .ok_or_else(|| StoreError::MissingEntity { - kind: "glyph layer", - id: key.clone(), - })?; + for (key, decoded_byte_length) in count_batch + .iter() + .zip(directory.decoded_byte_lengths.iter().copied()) + { if !batch.is_empty() && batch_decoded_bytes.saturating_add(decoded_byte_length) > MAX_LAYER_READ_BATCH_DECODED_BYTES { - layers.extend(load_glyph_layer_batch_from_conn(conn, &batch)?); + layers.extend(load_glyph_layer_batch_with_directory( + conn, + &batch, + &directory.facts, + )?); batch.clear(); batch_decoded_bytes = 0; } @@ -165,13 +142,18 @@ pub(crate) fn load_glyph_layers_from_conn( batch_decoded_bytes = batch_decoded_bytes.saturating_add(decoded_byte_length); } if !batch.is_empty() { - layers.extend(load_glyph_layer_batch_from_conn(conn, &batch)?); + layers.extend(load_glyph_layer_batch_with_directory( + conn, + &batch, + &directory.facts, + )?); } } Ok(layers) } +#[cfg(test)] pub(super) fn load_glyph_layer_batch_from_conn( conn: &Connection, layer_ids: &[font::LayerId], @@ -192,6 +174,29 @@ pub(super) fn load_glyph_layer_batch_from_conn( }); } + let directory = read_layer_directory(conn, &keys)?; + let batch_decoded_bytes = directory + .decoded_byte_lengths + .iter() + .fold(0_u64, |total, length| total.saturating_add(*length)); + if batch_decoded_bytes > MAX_LAYER_READ_BATCH_DECODED_BYTES { + return Err(StoreError::LayerReadBatchDecodedTooLarge { + bytes: batch_decoded_bytes, + limit: MAX_LAYER_READ_BATCH_DECODED_BYTES, + }); + } + let layer_ids = keys + .iter() + .cloned() + .map(font::LayerId::from_raw) + .collect::>(); + load_glyph_layer_batch_with_directory(conn, &layer_ids, &directory.facts) +} + +fn read_layer_directory( + conn: &Connection, + keys: &[String], +) -> Result { let placeholders = (0..keys.len()).map(|_| "?").collect::>().join(","); let directory_sql = format!( " @@ -211,30 +216,37 @@ pub(super) fn load_glyph_layer_batch_from_conn( map_directory_facts_row, )?; let mut facts = HashMap::with_capacity(keys.len()); - let mut batch_decoded_bytes = 0_u64; for row in directory_rows { let facts_row = row?; - let decoded_byte_length = facts_row.validate()?; - batch_decoded_bytes = batch_decoded_bytes.saturating_add(decoded_byte_length); facts.insert(facts_row.layer_id.clone(), facts_row); } drop(directory_stmt); - for key in &keys { - if !facts.contains_key(key) { - return Err(StoreError::MissingEntity { - kind: "glyph layer", - id: key.clone(), - }); - } - } - if batch_decoded_bytes > MAX_LAYER_READ_BATCH_DECODED_BYTES { - return Err(StoreError::LayerReadBatchDecodedTooLarge { - bytes: batch_decoded_bytes, - limit: MAX_LAYER_READ_BATCH_DECODED_BYTES, - }); + let mut decoded_byte_lengths = Vec::with_capacity(keys.len()); + for key in keys { + let facts = facts.get(key).ok_or_else(|| StoreError::MissingEntity { + kind: "glyph layer", + id: key.clone(), + })?; + decoded_byte_lengths.push(facts.validate()?); } + Ok(LayerDirectoryRead { + facts, + decoded_byte_lengths, + }) +} + +fn load_glyph_layer_batch_with_directory( + conn: &Connection, + layer_ids: &[font::LayerId], + facts: &HashMap, +) -> Result, StoreError> { + let keys = layer_ids + .iter() + .map(ToString::to_string) + .collect::>(); + let placeholders = (0..keys.len()).map(|_| "?").collect::>().join(","); let payload_sql = format!( " SELECT layer_id, payload, length(payload) diff --git a/crates/shift-workspace/docs/DOCS.md b/crates/shift-workspace/docs/DOCS.md index 98118897..015ec1cc 100644 --- a/crates/shift-workspace/docs/DOCS.md +++ b/crates/shift-workspace/docs/DOCS.md @@ -54,10 +54,12 @@ crates/shift-workspace/examples/ `FontWorkspace::inspect_package_draft(store_path)` reads the working-store package ownership record without resuming it. It returns the package id, source path, base fingerprint, document id, and dirty flag so the utility process can choose an explicit open transition. -`FontWorkspace::resume(store_path)` builds the eager directory skeleton without reading any layer BLOB. `acquire_glyphs(ids, AcquireScope::Glyphs)` fetches only requested layers; `AcquireScope::ComponentClosure` first expands component dependencies from the relational index. Acquisition passes the complete request to the store's shared count- and decoded-byte-aware planner. Each internal batch reads directory facts, payloads, and component indexes for at most 512 layers and 256 MiB decoded bytes, decompresses and verifies exact lengths plus BLAKE3 in parallel, accumulates the canonical results, and validates the complete replacement before mutating the uniquely owned live font in place. Validated identity sets become the final index entries rather than a temporary duplicate; shared font snapshots still use copy-on-write. A malformed batch does not replace the live cache. Save/export explicitly acquire all layers before creating their complete snapshots. +`FontWorkspace::resume(store_path)` builds the eager directory skeleton without reading any layer BLOB. `acquire_glyphs(ids, AcquireScope::Glyphs)` fetches only requested layers; `AcquireScope::ComponentClosure` first expands component dependencies from the relational index. Acquisition passes the complete request to the store's shared count- and decoded-byte-aware planner. Each directory fact is read once, then reused by bounded payload/component batches of at most 512 layers and 256 MiB decoded bytes. Batches decompress and verify exact lengths plus BLAKE3 in parallel, accumulate the canonical results, and validate the complete replacement before mutating the uniquely owned live font in place. Validated identity sets become the final index entries rather than a temporary duplicate; shared font snapshots still use copy-on-write. A malformed batch does not replace the live cache. Save/export explicitly acquire all layers before creating their complete snapshots. ## Profiling +Interactive package opens, working-store resumes, and foreign imports emit one `[workspace-open]` line to the utility-process log. Each line reports the relevant source, decode, SQLite, directory-materialization, and total durations without source paths. Streaming import stage durations overlap within `pipeline_wall_ms` and must not be summed. + `profile_streaming_import` uses the same public `stream_into` three-stage pipeline as the workspace and reports foreign-directory, parse, MessagePack encode, compression, SQLite write, commit, durable-finalization, native-directory materialization, reopen, decoded/stored BLOB, and database measurements without putting machine-specific timing assertions in tests: ```bash diff --git a/crates/shift-workspace/src/workspace.rs b/crates/shift-workspace/src/workspace.rs index aa6d7469..94ef690a 100644 --- a/crates/shift-workspace/src/workspace.rs +++ b/crates/shift-workspace/src/workspace.rs @@ -3,6 +3,7 @@ use std::{ io, path::{Path, PathBuf}, sync::Arc, + time::{Duration, Instant}, }; use shift_backends::{ @@ -25,6 +26,10 @@ use crate::source_identity::{ }; use crate::{NewWorkspace, stream_into}; +fn milliseconds(duration: Duration) -> f64 { + duration.as_secs_f64() * 1_000.0 +} + #[derive(Debug, thiserror::Error)] pub enum WorkspaceError { #[error(transparent)] @@ -174,16 +179,37 @@ impl FontWorkspace { } pub fn resume(store_path: impl AsRef) -> Result { + let total_started = Instant::now(); + let started = Instant::now(); let store = ShiftStore::open(store_path)?; + let store_open = started.elapsed(); + let started = Instant::now(); let state = store .workspace_state()? .ok_or_else(|| WorkspaceError::CorruptWorkingStore("missing workspace_state".into()))?; + let state_read = started.elapsed(); + let started = Instant::now(); let font = store.load_font_directory()?; + let directory_load = started.elapsed(); + let started = Instant::now(); let residency = LayerResidency::with_unloaded( font.glyphs() .flat_map(|glyph| glyph.layers().keys().cloned()), ); + let residency_build = started.elapsed(); + let started = Instant::now(); let source = source_from_workspace_state(&state)?; + let source_restore = started.elapsed(); + + println!( + "[workspace-open] mode=resume store_open_ms={:.3} state_read_ms={:.3} directory_load_ms={:.3} residency_build_ms={:.3} source_restore_ms={:.3} total_ms={:.3}", + milliseconds(store_open), + milliseconds(state_read), + milliseconds(directory_load), + milliseconds(residency_build), + milliseconds(source_restore), + milliseconds(total_started.elapsed()), + ); Ok(Self::from_store(font, source, store, residency)) } @@ -192,16 +218,38 @@ impl FontWorkspace { store_path: impl AsRef, source_path: impl AsRef, ) -> Result { + let total_started = Instant::now(); + let started = Instant::now(); let mut workspace = Self::resume(store_path)?; + let resume = started.elapsed(); + let started = Instant::now(); let identity = source_identity_snapshot(source_path)?; + let source_identity = started.elapsed(); + let started = Instant::now(); workspace.relink_package_source(identity)?; + let source_relink = started.elapsed(); + + println!( + "[workspace-open] mode=resume-for-source resume_ms={:.3} source_identity_ms={:.3} source_relink_ms={:.3} total_ms={:.3}", + milliseconds(resume), + milliseconds(source_identity), + milliseconds(source_relink), + milliseconds(total_started.elapsed()), + ); + Ok(workspace) } pub fn inspect_package( source_path: impl AsRef, ) -> Result { - package_identity(source_path) + let started = Instant::now(); + let identity = package_identity(source_path)?; + println!( + "[workspace-open] mode=inspect-package total_ms={:.3}", + milliseconds(started.elapsed()), + ); + Ok(identity) } pub fn inspect_package_draft( @@ -721,13 +769,40 @@ impl FontWorkspace { source_path: impl AsRef, store_path: impl AsRef, ) -> Result { + let total_started = Instant::now(); + let started = Instant::now(); let source_package = ShiftSourcePackage::open(source_path)?; + let package_validate = started.elapsed(); + let started = Instant::now(); let mut store = ShiftStore::open(store_path)?; + let store_open = started.elapsed(); + let started = Instant::now(); let font = ShiftSourcePackage::load_font(source_package.path())?; + let package_load = started.elapsed(); + let started = Instant::now(); store.set_font_info(font_info_from_font(&font))?; + let font_info_write = started.elapsed(); + let started = Instant::now(); store.replace_font_state(&font)?; + let font_state_write = started.elapsed(); + let started = Instant::now(); let identity = source_identity_snapshot(source_package.path())?; + let source_identity = started.elapsed(); + let started = Instant::now(); store.set_workspace_state(WorkspaceState::package(identity, None))?; + let workspace_state_write = started.elapsed(); + + println!( + "[workspace-open] mode=package package_validate_ms={:.3} store_open_ms={:.3} package_load_ms={:.3} font_info_write_ms={:.3} font_state_write_ms={:.3} source_identity_ms={:.3} workspace_state_write_ms={:.3} total_ms={:.3}", + milliseconds(package_validate), + milliseconds(store_open), + milliseconds(package_load), + milliseconds(font_info_write), + milliseconds(font_state_write), + milliseconds(source_identity), + milliseconds(workspace_state_write), + milliseconds(total_started.elapsed()), + ); Ok(Self::from_store( font, @@ -743,13 +818,16 @@ impl FontWorkspace { import_path: impl AsRef, store_path: impl AsRef, ) -> Result { + let total_started = Instant::now(); let import_path = import_path.as_ref(); let import_path_str = import_path .to_str() .ok_or_else(|| WorkspaceError::InvalidPathUtf8(import_path.to_path_buf()))?; let loader = FontLoader::new(); + let started = Instant::now(); match loader.stream_font(import_path_str) { Ok(import) => { + let source_directory = started.elapsed(); let store_path = store_path.as_ref(); if store_path.exists() { let existing = ShiftStore::open(store_path)?; @@ -761,21 +839,73 @@ impl FontWorkspace { } } + let started = Instant::now(); let staged_path = create_import_staging_path(store_path)?; let mut store = ShiftStore::open_for_import(&staged_path)?; let mut writer = store.begin_import(import.header())?; - stream_into(import, &mut writer, ImportBatchLimit::default(), |_| {})?; + let store_setup = started.elapsed(); + let pipeline_started = Instant::now(); + let mut parse = Duration::ZERO; + let mut pack = Duration::ZERO; + let mut compression = Duration::ZERO; + let mut sqlite = Duration::ZERO; + let mut glyph_count = 0; + let mut layer_count = 0; + let mut batch_count = 0; + stream_into(import, &mut writer, ImportBatchLimit::default(), |batch| { + parse += batch.parse_elapsed; + pack += batch.pack_elapsed; + compression += batch.compression_elapsed; + sqlite += batch.sqlite_elapsed; + glyph_count += batch.glyph_count; + layer_count += batch.layer_count; + batch_count += 1; + })?; + let pipeline_wall = pipeline_started.elapsed(); + let started = Instant::now(); writer.finish()?; + let commit = started.elapsed(); + let started = Instant::now(); store.set_workspace_state(WorkspaceState::imported(import_path, None))?; + let workspace_state_write = started.elapsed(); + let started = Instant::now(); store.finish_import()?; + let store_finalize = started.elapsed(); + let started = Instant::now(); let font = store.load_font_directory()?; + let directory_load = started.elapsed(); + let started = Instant::now(); let residency = LayerResidency::with_unloaded( font.glyphs() .flat_map(|glyph| glyph.layers().keys().cloned()), ); + let residency_build = started.elapsed(); drop(store); + let started = Instant::now(); install_import_store(staged_path, store_path)?; + let store_install = started.elapsed(); + let started = Instant::now(); let store = ShiftStore::open(store_path)?; + let store_reopen = started.elapsed(); + + println!( + "[workspace-open] mode=import-stream glyphs={glyph_count} layers={layer_count} batches={batch_count} source_directory_ms={:.3} store_setup_ms={:.3} pipeline_wall_ms={:.3} parse_ms={:.3} pack_ms={:.3} compression_ms={:.3} sqlite_ms={:.3} commit_ms={:.3} workspace_state_write_ms={:.3} store_finalize_ms={:.3} directory_load_ms={:.3} residency_build_ms={:.3} store_install_ms={:.3} store_reopen_ms={:.3} total_ms={:.3}", + milliseconds(source_directory), + milliseconds(store_setup), + milliseconds(pipeline_wall), + milliseconds(parse), + milliseconds(pack), + milliseconds(compression), + milliseconds(sqlite), + milliseconds(commit), + milliseconds(workspace_state_write), + milliseconds(store_finalize), + milliseconds(directory_load), + milliseconds(residency_build), + milliseconds(store_install), + milliseconds(store_reopen), + milliseconds(total_started.elapsed()), + ); return Ok(Self::from_store( font, @@ -789,12 +919,34 @@ impl FontWorkspace { Err(shift_backends::BackendError::StreamingUnsupported { .. }) => {} Err(error) => return Err(error.into()), } + let stream_probe = started.elapsed(); + let started = Instant::now(); let font = loader.read_font(import_path_str)?; + let font_load = started.elapsed(); + let started = Instant::now(); let mut store = ShiftStore::open(store_path)?; + let store_open = started.elapsed(); + let started = Instant::now(); store.set_font_info(font_info_from_font(&font))?; + let font_info_write = started.elapsed(); + let started = Instant::now(); store.replace_font_state(&font)?; + let font_state_write = started.elapsed(); + let started = Instant::now(); store.set_workspace_state(WorkspaceState::imported(import_path, None))?; + let workspace_state_write = started.elapsed(); + + println!( + "[workspace-open] mode=import-eager stream_probe_ms={:.3} font_load_ms={:.3} store_open_ms={:.3} font_info_write_ms={:.3} font_state_write_ms={:.3} workspace_state_write_ms={:.3} total_ms={:.3}", + milliseconds(stream_probe), + milliseconds(font_load), + milliseconds(store_open), + milliseconds(font_info_write), + milliseconds(font_state_write), + milliseconds(workspace_state_write), + milliseconds(total_started.elapsed()), + ); Ok(Self::from_store( font, diff --git a/packages/types/src/bridge/generated.ts b/packages/types/src/bridge/generated.ts index ff7adf23..545ff4bb 100644 --- a/packages/types/src/bridge/generated.ts +++ b/packages/types/src/bridge/generated.ts @@ -84,6 +84,14 @@ export interface BridgeApi { * `get_glyph_snapshots`. */ getGlyphPreviews(glyphIds: Array, location: Location): Array + /** + * Acquires every authored layer and compiles the complete location-independent atlas. + * + * The utility process calls this after opening a workspace so acquisition and + * compilation overlap renderer and WebGPU startup. Alignment-specific layout + * remains deferred until `prepare_slug_atlas` knows the adapter requirement. + */ + prepareAuthoredGlyphCompilation(): void /** * Builds one complete authored Slug generation without resolving a location. *