diff --git a/Cargo.lock b/Cargo.lock index cfcb8eb5..988563ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1697,7 +1697,7 @@ dependencies = [ "norad 0.16.0", "plist", "quick-xml", - "shift-ir", + "shift-font", "skrifa", "tempfile", "thiserror 2.0.18", @@ -1713,8 +1713,7 @@ dependencies = [ "serde", "serde_json", "shift-backends", - "shift-edit", - "shift-ir", + "shift-font", "shift-wire", "thiserror 2.0.18", ] @@ -1723,29 +1722,12 @@ dependencies = [ name = "shift-document" version = "0.1.0" dependencies = [ - "shift-ir", + "shift-font", "shift-store", "tempfile", "thiserror 2.0.18", ] -[[package]] -name = "shift-edit" -version = "0.0.0" -dependencies = [ - "bitflags", - "fontc", - "fontdrasil 0.4.0", - "norad 0.16.0", - "serde", - "serde_json", - "shift-backends", - "shift-ir", - "shift-wire", - "skrifa", - "thiserror 2.0.18", -] - [[package]] name = "shift-font" version = "0.1.0" @@ -1755,17 +1737,7 @@ dependencies = [ "kurbo 0.13.0", "linesweeper", "serde", -] - -[[package]] -name = "shift-ir" -version = "0.1.0" -dependencies = [ - "fontdrasil 0.4.0", - "indexmap", - "kurbo 0.13.0", - "linesweeper", - "serde", + "thiserror 2.0.18", ] [[package]] @@ -1781,10 +1753,11 @@ dependencies = [ name = "shift-wire" version = "0.0.0" dependencies = [ + "fontdrasil 0.4.0", "napi", "napi-derive", "serde", - "shift-ir", + "shift-font", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d26e7071..91d7391f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,8 +7,6 @@ members = ["crates/*"] shift-wire = { path = "crates/shift-wire" } shift-backends = { path = "crates/shift-backends" } shift-bridge = { path = "crates/shift-bridge" } -shift-edit = { path = "crates/shift-edit" } shift-font = { path = "crates/shift-font" } -shift-ir = { path = "crates/shift-ir" } shift-document = { path = "crates/shift-document" } shift-store = { path = "crates/shift-store" } diff --git a/Claude.md b/Claude.md index e4f36baf..a62adb65 100644 --- a/Claude.md +++ b/Claude.md @@ -88,7 +88,7 @@ This project uses **pnpm** (v9.0.0) as its package manager. ## Project Structure - `apps/desktop/src/` - Electron app (main, preload, renderer, shared) -- `crates/` - Rust workspace (shift-core, shift-backends, shift-ir, shift-node) +- `crates/` - Rust workspace (shift-font, shift-backends, shift-bridge, shift-store) - `packages/` - TypeScript packages (types, geo, font, ui) ## Code Organization Rules diff --git a/ROADMAP.md b/ROADMAP.md index d8e52b46..4179d634 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -235,7 +235,7 @@ These are allowed to jump around when energy is high, but they should not silent **Edit Operations** -- [x] EditSession ownership model for glyph editing +- [x] Glyph layer mutation ownership model for glyph editing - [x] Add/remove points - [x] Move points (single and batch) - [x] Add/remove contours diff --git a/apps/desktop/src/main/managers/AppLifecycle.ts b/apps/desktop/src/main/managers/AppLifecycle.ts index e7452ad7..cc7786c7 100644 --- a/apps/desktop/src/main/managers/AppLifecycle.ts +++ b/apps/desktop/src/main/managers/AppLifecycle.ts @@ -193,6 +193,7 @@ export class AppLifecycle { if (!shouldOpen) return; } + // OK this is the problem, we are sending an open file event to the renderer to open the file from the bridge ipc.send(window.webContents, "external:open-font", filePath); } diff --git a/apps/desktop/src/renderer/src/app/App.tsx b/apps/desktop/src/renderer/src/app/App.tsx index ed6c259a..f4384026 100644 --- a/apps/desktop/src/renderer/src/app/App.tsx +++ b/apps/desktop/src/renderer/src/app/App.tsx @@ -79,6 +79,9 @@ export const App = () => { } const unsubscribeOpen = window.electronAPI?.onMenuOpenFont(handleOpenFont); + + // TODO: have all this annoying logic for when HMR happens to re-open the already open font + // this should be handled more gracefully. const unsubscribeExternalOpen = window.electronAPI?.onExternalOpenFont(handleOpenFont); const unsubscribeNew = window.electronAPI?.onDocumentNew(() => { fontDocument.createFont(); @@ -106,6 +109,7 @@ export const App = () => { dumpSelectionPatternsToConsole(); }); + // TODO: I image these can get stale (or maybe not, they don't capture anything) return () => { if (unsubscribeNew) unsubscribeNew(); if (unsubscribeOpen) unsubscribeOpen(); diff --git a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts index f237f0ee..073fdcc0 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts @@ -71,7 +71,9 @@ describe("Clipboard (via Editor)", () => { await editor.paste(); await editor.paste(); - const contours = (editor.currentGlyph?.contours ?? []).filter((contour) => !contour.isEmpty); + const contours = (editor.activeGlyphSource?.contours ?? []).filter( + (contour) => !contour.isEmpty, + ); expect(contours).toHaveLength(3); // Each paste translates the original by DEFAULT_PASTE_OFFSET (20) * diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/ApplyPositionPatchCommand.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/ApplyPositionPatchCommand.test.ts index 29534886..3ee191d5 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/ApplyPositionPatchCommand.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/ApplyPositionPatchCommand.test.ts @@ -14,7 +14,6 @@ function editableSource(): GlyphSource { const handle = { name: "A", unicode: 65 }; const source = font.defaultSource; - bridge.startEditSession(handle, source.id); const glyphSource = font.glyphSource(handle, source); if (!glyphSource) throw new Error("Expected editable glyph source"); diff --git a/apps/desktop/src/renderer/src/lib/commands/testUtils.ts b/apps/desktop/src/renderer/src/lib/commands/testUtils.ts index bb3a81e7..510b4c88 100644 --- a/apps/desktop/src/renderer/src/lib/commands/testUtils.ts +++ b/apps/desktop/src/renderer/src/lib/commands/testUtils.ts @@ -20,7 +20,6 @@ export function commandSourceFixture(): CommandSourceFixture { const handle = { name: "A", unicode: 65 }; const source = font.defaultSource; - bridge.startEditSession(handle, source.id); const glyphSource = font.glyphSource(handle, source); if (!glyphSource) throw new Error("Expected editable glyph source"); diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index f646fbe4..d4e74752 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -496,7 +496,7 @@ export class Editor { * This is a read/focus API. It chooses the current active source context for * camera metrics, asks `Font` for existing glyph state, and updates * `editingGlyph` when the glyph can be loaded. It does not create missing - * glyph data and does not start an edit session. + * glyph data and does not select an editable source. * * @returns The focused glyph model, or `null` when the glyph has no readable state. */ @@ -541,10 +541,6 @@ export class Editor { const source = this.font.source(sourceId); if (!source) return null; - if (this.#bridge.hasEditSession()) { - this.#bridge.endEditSession(); - } - this.#bridge.startEditSession(handle, source.id); this.#glyph.edit.selectSource(source.id); const glyph = this.getGlyph(handle); @@ -593,7 +589,7 @@ export class Editor { * TextRuns.resolveAnchor(anchor) * │ * ▼ - * native edit session + drawOffset = focused.editOrigin + * source glyph geometry + drawOffset = focused.editOrigin */ public setGlyphFocus(anchor: GlyphAnchor): void { const focused = this.#textRuns.resolveAnchor(anchor); @@ -625,11 +621,8 @@ export class Editor { return this.#text.glyphPlacement.peek(); } - /** Ends the current editing session. */ + /** Clears the current glyph focus and active contour selection. */ public close(): void { - if (this.#bridge.hasEditSession()) { - this.#bridge.endEditSession(); - } this.#glyph.open.glyph.set(null); this.#glyph.open.activeContourId.set(null); } @@ -663,6 +656,20 @@ export class Editor { return this.#glyph.edit.source.peek(); } + /** + * Returns the authored glyph source currently targeted by edit commands. + * + * @remarks + * This is the source-backed edit target, not the interpolated preview + * geometry shown at the current design location. Clipboard, command, and test + * code should use this when reading or mutating authored point data. + * + * @returns null when no glyph source is open for editing. + */ + public get activeGlyphSource(): GlyphSource | null { + return this.#glyph.edit.glyphSource.peek(); + } + /** Glyph instance resolved at the current design location. */ public get glyphInstance(): GlyphInstance | null { return this.#glyph.preview.instance.peek(); @@ -1025,10 +1032,6 @@ export class Editor { * default design location. */ public createFont(): void { - if (this.#bridge.hasEditSession()) { - this.close(); - } - this.font.create(); this.setDesignLocation(this.font.defaultLocation()); this.#events.emit("fontLoaded", { font: this.font }); @@ -1039,20 +1042,12 @@ export class Editor { * location. */ public loadFont(filePath: string): void { - if (this.#bridge.hasEditSession()) { - this.close(); - } - this.font.load(filePath); this.setDesignLocation(this.font.defaultLocation()); this.#events.emit("fontLoaded", { font: this.font }); } public closeFont(): void { - if (this.#bridge.hasEditSession()) { - this.close(); - } - this.font.close(); this.setDesignLocation(emptyAxisLocation()); } diff --git a/apps/desktop/src/renderer/src/lib/editor/SourceEditDraft.test.ts b/apps/desktop/src/renderer/src/lib/editor/SourceEditDraft.test.ts index 592959f2..3f1d61eb 100644 --- a/apps/desktop/src/renderer/src/lib/editor/SourceEditDraft.test.ts +++ b/apps/desktop/src/renderer/src/lib/editor/SourceEditDraft.test.ts @@ -15,7 +15,6 @@ function editableSource(): GlyphSource { const handle = { name: "A", unicode: 65 }; const source = font.defaultSource; - bridge.startEditSession(handle, source.id); const glyphSource = font.glyphSource(handle, source); if (!glyphSource) throw new Error("Expected editable glyph source"); diff --git a/apps/desktop/src/renderer/src/lib/editor/variation.test.ts b/apps/desktop/src/renderer/src/lib/editor/variation.test.ts index 3b356b19..4d1d6d43 100644 --- a/apps/desktop/src/renderer/src/lib/editor/variation.test.ts +++ b/apps/desktop/src/renderer/src/lib/editor/variation.test.ts @@ -18,10 +18,10 @@ function flattenPoints(g: Glyph | GlyphGeometry): number[] { return out; } -describe("Editor.open — variation-aware edit sessions", () => { +describe("Editor.open — variation-aware glyph reads", () => { it("opens a glyph with values interpolated at the current variation location", () => { - // Regression for 1c2c575: opening a glyph from the grid used to start an - // edit session at the master's stored coordinates, so the canvas didn't + // Regression for 1c2c575: opening a glyph from the grid used to read the + // master's stored coordinates, so the canvas didn't // match what the user clicked when the slider was off-default. const editor = new TestEditor(); editor.loadFont(MUTATORSANS_DESIGNSPACE); @@ -63,9 +63,9 @@ describe("Editor.open — variation-aware edit sessions", () => { expect(samePoint?.x).toBe(movedX); }); - it("edits to a glyph are visible from the registry after closing the session", () => { - // The grid renders via `font.glyph(name)` (not the editor) — so after a - // session ends, the registry's Glyph must reflect the edits the user + it("edits to a glyph are visible from the registry after closing the glyph", () => { + // The grid renders via `font.glyph(name)` (not the editor), so after the + // glyph closes, the registry's Glyph must reflect the edits the user // just made. Otherwise the grid shows the pre-edit outline. const editor = new TestEditor(); editor.loadFont(MUTATORSANS_DESIGNSPACE); diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts index ac0521b7..fe494aba 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts @@ -22,7 +22,7 @@ interface Fixture { } function loadFixture(): Fixture { - // Generated by `cargo test -p shift-edit --test interpolation_parity`. + // Generated from the Rust interpolation parity fixture. // vitest runs with cwd at apps/desktop; repo root is two up. const path = resolve(process.cwd(), "../../packages/types/__fixtures__/variation_parity.json"); return JSON.parse(readFileSync(path, "utf-8")) as Fixture; diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index d0e7b09d..a4ef3625 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -7,7 +7,7 @@ * `scalar_at_with_args` and `interpolate_from_deltas`. * * Parity-tested against `packages/types/__fixtures__/variation_parity.json`, - * generated by `crates/shift-edit/tests/interpolation_parity.rs`. + * generated by the Rust interpolation parity fixture. */ import type { Axis, AxisTent, GlyphVariationData } from "@shift/types"; @@ -16,7 +16,7 @@ import type { AxisLocation } from "@/types/variation"; export type NormalizedLocation = Record; -/** Asymmetric normalize — mirrors `Axis::normalize` in shift-ir/src/axis.rs. */ +/** Asymmetric normalize — mirrors `Axis::normalize` in `shift-font`. */ export function normalizeAxis(value: number, axis: Axis): number { if (value < axis.default) { const range = axis.default - axis.minimum; @@ -76,7 +76,7 @@ export function scalarAt(loc: NormalizedLocation, region: AxisTent[]): number { * Output values are absolute (not offsets from default) because the default * master's region has empty/zero support and contributes its full delta at scalar=1. * - * Order is the `flatten()` shape from shift-edit::interpolation: + * Order is the Rust interpolation `flatten()` shape: * [xAdvance, p0.x, p0.y, p1.x, p1.y, ..., a0.x, a0.y, ...] */ export function interpolate(data: GlyphVariationData, loc: NormalizedLocation): Float64Array { diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index 65433948..8b5454b8 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -422,8 +422,8 @@ export class Font { * @param fromName - Existing committed glyph name. * @param name - New unique glyph name after trimming whitespace. * @param unicodes - Complete Unicode assignment for the renamed glyph. - * @throws {Error} when `fromName` is missing, `name` is empty, `name` already - * exists, or an edit session is active. + * @throws {Error} when `fromName` is missing, `name` is empty, or `name` + * already exists. */ updateGlyphIdentity(fromName: GlyphName, name: GlyphName, unicodes: readonly Unicode[]): void { this.#bridge.updateGlyphIdentity(fromName, name.trim() as GlyphName, [...unicodes]); @@ -437,23 +437,25 @@ export class Font { * * @remarks * If the requested name already exists, a numeric suffix is appended using - * {@link nextAvailableGlyphName}. The bridge edit session is opened and - * immediately ended so downstream save/export paths see a real committed - * glyph record, not a UI-only placeholder. + * {@link nextAvailableGlyphName}. The bridge receives an explicit glyph layer + * mutation so downstream save/export paths see a real committed glyph record, + * not a UI-only placeholder. * * @param name - Preferred glyph name. Blank input falls back to `newGlyph`. * @returns The handle for the glyph that was actually created. - * @throws {Error} when the bridge rejects session creation or commit. + * @throws {Error} when the bridge rejects glyph creation. */ createGlyph(name: GlyphName): GlyphHandle { const glyphName = this.nextAvailableGlyphName(name); - if (this.#bridge.hasEditSession()) { - this.#bridge.endEditSession(); - } const handle = this.glyphHandleForName(glyphName); - this.#bridge.startEditSession(handle, this.defaultSource.id); - this.#bridge.endEditSession(); + this.#bridge.setXAdvance( + { + glyphHandle: handle, + layerId: this.defaultSource.layerId, + }, + 500, + ); this.#glyphs.clear(); this.#glyphSources.clear(); @@ -501,9 +503,9 @@ export class Font { /** * Get the cached model for an existing glyph. * - * This is a read/data access API. It asks the bridge for committed or active - * glyph state at the font's default source and returns `null` when no state - * exists. It does not create missing glyphs and does not start an edit session. + * This is a read/data access API. It asks the bridge for glyph state at the + * font's default source and returns `null` when no state exists. It does not + * create missing glyphs or select an editable source. * * @example * ```ts @@ -577,8 +579,8 @@ export class Font { * Read raw glyph state for a source from the bridge. * * This is the lowest-level glyph data read used by the domain model. The - * bridge may return active edit-session state, committed font state, or - * `null` when the glyph has no data for the source. + * bridge may return native glyph-layer state or `null` when the glyph has no + * data for the source. * * @returns Raw glyph state, or `null` when the bridge cannot provide state. */ diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts index 8080f7bd..95b98e43 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts @@ -25,7 +25,6 @@ function editGlyph(): { const handle = { name: "A" }; const source = font.defaultSource; - bridge.startEditSession(handle, source.id); const glyph = font.glyph(handle); if (!glyph) throw new Error("Expected edit glyph"); @@ -107,7 +106,7 @@ describe("Glyph", () => { layer = nextLayer; }); - it("hydrates state from the active edit session", () => { + it("hydrates state from the native glyph layer", () => { expect(glyph.name).toBe("A"); expect(glyph.unicode).toBeNull(); expect(glyph.xAdvance).toBeGreaterThan(0); diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.ts index cb434c3b..babc6467 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.ts @@ -3,6 +3,7 @@ import type { ContourData, ContourId, GlyphName, + GlyphLayerRef, GlyphState, GlyphStructure, GlyphStructureChange, @@ -118,30 +119,26 @@ class GlyphEditSession { } setXAdvance(width: number): void { - this.#ensureActiveSession(); - this.#applyValueChange(this.#font.bridge.setXAdvance(width)); + this.#applyValueChange(this.#font.bridge.setXAdvance(this.#glyphRef(), width)); } applyPositionPatch(updates: SourcePositions): void { const patch = SourcePositionPatch.from(updates); if (patch.isEmpty) return; - this.#ensureActiveSession(); - this.#font.bridge.applyPositionPatch(...patch.toBridgePayload()); + this.#font.bridge.applyPositionPatch(this.#glyphRef(), ...patch.toBridgePayload()); this.#applyPositionPatchLocally(patch.positions); } commitPositionPatch(updates: SourcePositions): void { const patch = SourcePositionPatch.from(updates); if (patch.isEmpty) return; - this.#ensureActiveSession(); - this.#font.bridge.applyPositionPatch(...patch.toBridgePayload()); + this.#font.bridge.applyPositionPatch(this.#glyphRef(), ...patch.toBridgePayload()); } translateLayer(dx: number, dy: number): void { - this.#ensureActiveSession(); - this.#applyValueChange(this.#font.bridge.translateLayer(dx, dy)); + this.#applyValueChange(this.#font.bridge.translateLayer(this.#glyphRef(), dx, dy)); } previewPositionPatch(updates: SourcePositions): void { @@ -154,8 +151,7 @@ class GlyphEditSession { } addContour(): ContourId { - this.#ensureActiveSession(); - const change = this.#font.bridge.addContour(); + const change = this.#font.bridge.addContour(this.#glyphRef()); this.#applyStructureChange(change); const contourId = change.changed.contourIds[0]; if (!contourId) throw new Error("Bridge did not return a created contour ID"); @@ -163,9 +159,8 @@ class GlyphEditSession { } addPoint(contourId: ContourId, edit: NewPoint): PointId { - this.#ensureActiveSession(); - const change = this.#font.bridge.addPoint( + this.#glyphRef(), contourId, edit.x, edit.y, @@ -182,8 +177,8 @@ class GlyphEditSession { } insertPointBefore(beforePointId: PointId, edit: NewPoint): PointId { - this.#ensureActiveSession(); const change = this.#font.bridge.insertPointBefore( + this.#glyphRef(), beforePointId, edit.x, edit.y, @@ -197,18 +192,15 @@ class GlyphEditSession { } openContour(contourId: ContourId): void { - this.#ensureActiveSession(); - this.#applyStructureChange(this.#font.bridge.openContour(contourId)); + this.#applyStructureChange(this.#font.bridge.openContour(this.#glyphRef(), contourId)); } closeContour(contourId: ContourId): void { - this.#ensureActiveSession(); - this.#applyStructureChange(this.#font.bridge.closeContour(contourId)); + this.#applyStructureChange(this.#font.bridge.closeContour(this.#glyphRef(), contourId)); } reverseContour(contourId: ContourId): void { - this.#ensureActiveSession(); - this.#applyStructureChange(this.#font.bridge.reverseContour(contourId)); + this.#applyStructureChange(this.#font.bridge.reverseContour(this.#glyphRef(), contourId)); } applyBooleanOp( @@ -216,40 +208,31 @@ class GlyphEditSession { contourIdB: ContourId, operation: "union" | "subtract" | "intersect" | "difference", ): void { - this.#ensureActiveSession(); - this.#applyStructureChange(this.#font.bridge.applyBooleanOp(contourIdA, contourIdB, operation)); + this.#applyStructureChange( + this.#font.bridge.applyBooleanOp(this.#glyphRef(), contourIdA, contourIdB, operation), + ); } removePoints(pointIds: readonly PointId[]): void { if (pointIds.length === 0) return; - this.#ensureActiveSession(); - this.#applyStructureChange(this.#font.bridge.removePoints([...pointIds])); + this.#applyStructureChange(this.#font.bridge.removePoints(this.#glyphRef(), [...pointIds])); } toggleSmooth(pointId: PointId): void { - this.#ensureActiveSession(); - this.#applyStructureChange(this.#font.bridge.toggleSmooth(pointId)); + this.#applyStructureChange(this.#font.bridge.toggleSmooth(this.#glyphRef(), pointId)); } restore(state: GlyphState): void { - this.#ensureActiveSession(); - this.#applyStructureChange(this.#font.bridge.restoreState(state.structure, state.values)); + this.#applyStructureChange( + this.#font.bridge.restoreState(this.#glyphRef(), state.structure, state.values), + ); } - #ensureActiveSession(): void { - const bridge = this.#font.bridge; - if ( - bridge.getEditingGlyphName() === this.#handle.name && - bridge.getEditingSourceId() === this.#source.id - ) { - return; - } - - if (bridge.hasEditSession()) { - bridge.endEditSession(); - } - - bridge.startEditSession(this.#handle, this.#source.id); + #glyphRef(): GlyphLayerRef { + return { + glyphHandle: this.#handle, + layerId: this.#source.layerId, + }; } #applyStructureChange(change: GlyphStructureChange): void { @@ -269,8 +252,8 @@ class GlyphEditSession { * * A source is the authored glyph at a designspace location. `GlyphSource` * exposes the reactive geometry for that source and forwards mutations to the - * active bridge edit session. Preview methods update the renderer-facing - * reactive data; commit methods also produce bridge changes. + * bridge with an explicit glyph-layer reference. Preview methods update the + * renderer-facing reactive data; commit methods also produce bridge changes. */ export class GlyphSource { readonly source: Source; @@ -430,7 +413,7 @@ export class GlyphSource { * * Use this after the same patch has already been applied locally with * {@link previewPositionPatch}. This is the drag-end path: it updates the - * active Rust edit session without replacing TypeScript geometry. + * native glyph layer without replacing TypeScript geometry. * * @param updates - Final point and anchor positions to persist. */ @@ -717,7 +700,7 @@ export class GlyphSource { * Replace this source's editable state. * * Used by undo/redo and command rollback. This mutates the source model and - * syncs the active bridge edit session back to the restored state. + * syncs the native glyph layer back to the restored state. * * @param state - Source state snapshot to restore. */ diff --git a/apps/desktop/src/renderer/src/lib/text/TextRuns.ts b/apps/desktop/src/renderer/src/lib/text/TextRuns.ts index b86f0c19..fc636b36 100644 --- a/apps/desktop/src/renderer/src/lib/text/TextRuns.ts +++ b/apps/desktop/src/renderer/src/lib/text/TextRuns.ts @@ -2,7 +2,7 @@ * TextRuns — per-glyph store of TextRun instances. * * One TextRun per glyph name (each glyph carries its own typing context - * across edit sessions). Plus a default-active run keyed by `__default__` + * across glyph editing changes). Plus a default-active run keyed by `__default__` * for cases where no specific glyph owns the run yet. * * Active run is selected via `switchTo(glyphName | null)`, which returns diff --git a/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.ts b/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.ts index 625eb0f1..b9842869 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.ts @@ -157,6 +157,7 @@ export class GestureDetector { return [{ type: "pointerMove", point: coords.scene, coords }]; } + // TODO: make this use Vec library const distance = Math.hypot( screenPoint.x - this.downScreenPoint.x, screenPoint.y - this.downScreenPoint.y, @@ -186,10 +187,12 @@ export class GestureDetector { screenPoint, origin: this.downPoint, screenOrigin: this.downScreenPoint, + // TODO: Vec delta: { x: coords.scene.x - this.downPoint.x, y: coords.scene.y - this.downPoint.y, }, + // TODO: Vec screenDelta: { x: screenPoint.x - this.downScreenPoint.x, y: screenPoint.y - this.downScreenPoint.y, @@ -228,7 +231,8 @@ export class GestureDetector { const now = Date.now(); const timeSinceLastClick = now - this.lastClickTime; const distFromLastClick = this.lastClickPoint - ? Math.hypot(point.x - this.lastClickPoint.x, point.y - this.lastClickPoint.y) + ? //TODO: VEC + Math.hypot(point.x - this.lastClickPoint.x, point.y - this.lastClickPoint.y) : Infinity; if ( diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/Pen.test.ts b/apps/desktop/src/renderer/src/lib/tools/pen/Pen.test.ts index b3877f6b..ba7d932e 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/Pen.test.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/Pen.test.ts @@ -13,34 +13,70 @@ describe("Pen tool", () => { describe("point creation", () => { it("adds a point on click", () => { editor.click(100, 200); - expect(editor.pointCount).toBe(1); - }); - it("adds multiple points", () => { - editor.click(100, 200); - editor.click(300, 400); - expect(editor.pointCount).toBe(2); + const contour = editor.getActiveContour(); + expect(contour?.points.length).toBe(1); }); }); - describe("state transitions", () => { - it("starts in ready state after activation", () => { - const state = editor.toolManager.activeTool?.getState(); - expect(state?.type).toBe("ready"); + describe("creating segments", () => { + it("adding two points creates a line segment", () => { + editor.click(100, 200); + editor.click(300, 200); + + const segment = editor.getActiveContour()?.segments()[0]; + + expect(segment?.type).toBe("line"); }); - it("returns to ready after placing a point", () => { + it("adding three points creates two line segments", () => { editor.click(100, 200); - const state = editor.toolManager.activeTool?.getState(); - expect(state?.type).toBe("ready"); + editor.click(300, 200); + editor.click(500, 200); + + const contour = editor.getActiveContour(); + expect(contour?.segments().length).toBe(2); + + const segmentOne = editor.getActiveContour()?.segments()[0]; + const segmentTwo = editor.getActiveContour()?.segments()[1]; + + expect(segmentOne?.type).toBe("line"); + expect(segmentTwo?.type).toBe("line"); }); - }); - describe("cancel", () => { - it("handles escape in ready state without error", () => { - editor.escape(); - const state = editor.toolManager.activeTool?.getState(); - expect(state?.type).toBe("ready"); + it("adding three points and then pointer down on the first point should close the contour and set the active contour to null", () => {}); + + it("adding a pointand then dragging should create a cubic curve", () => { + editor.click(200, -800); + editor.pointerDown(200, -800); + editor.pointerMove(400, 120); + editor.pointerMove(400, 140); + editor.pointerMove(400, 160); + editor.pointerUp(200, -200); + + const contour = editor.getActiveContour(); + expect(contour?.segments().length).toBe(1); + + const segment = editor.getActiveContour()?.segments()[0]; + expect(segment?.type).toBe("cubic"); }); + + it("creating two cubic curves should create two cubic segments, with a smooth point at their junction", () => {}); + }); + + describe("pointer down on segment when no contour is active", () => { + it("pointer down on the last point of the segment, sets that contour as active and contour"); + it( + "pointer down on the first point of the segment, sets that contour as active and reverses the contour", + ); + it( + "pointer down between the first and last point on the segment, splits the segment at that point", + ); + }); + + describe("pen cursors", () => { + it("active cursor"); + it("continue cursor"); + it("split cursor"); }); }); diff --git a/apps/desktop/src/renderer/src/lib/tools/select/Select.ts b/apps/desktop/src/renderer/src/lib/tools/select/Select.ts index 79827f93..8089684c 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/Select.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/Select.ts @@ -93,10 +93,14 @@ export class Select extends BaseTool { } override drawOverlay(canvas: Canvas): void { + // TODO: perhaps there should be a way for tools to turn on/off bounding box + // rendering without it having to be a commit in the Select Tool + const otherToolState = this.editor.getActiveToolState().type; const isMutatingState = this.state.type === "translating" || this.state.type === "resizing" || - this.state.type === "rotating"; + this.state.type === "rotating" || + otherToolState === "bend"; if (!isMutatingState) { this.boundingBox.draw(canvas); diff --git a/apps/desktop/src/renderer/src/perf/interaction.bench.ts b/apps/desktop/src/renderer/src/perf/interaction.bench.ts index 858ec6f9..eb2466a9 100644 --- a/apps/desktop/src/renderer/src/perf/interaction.bench.ts +++ b/apps/desktop/src/renderer/src/perf/interaction.bench.ts @@ -20,8 +20,11 @@ function setupDrag(scale: PointScale) { const pm = createPointMark(scale); pm.editor.selectTool("select"); - const glyph = pm.editor.currentGlyph!; + const glyph = pm.editor.activeGlyphSource; + if (!glyph) throw new Error("No editable glyph source after point-mark setup"); const firstPoint = glyph.allPoints[0]; + if (!firstPoint) throw new Error("Point-mark setup did not create any points"); + const startX = firstPoint.x; const startY = firstPoint.y; diff --git a/apps/desktop/src/renderer/src/testing/TestEditor.ts b/apps/desktop/src/renderer/src/testing/TestEditor.ts index e2311603..edaa8db5 100644 --- a/apps/desktop/src/renderer/src/testing/TestEditor.ts +++ b/apps/desktop/src/renderer/src/testing/TestEditor.ts @@ -1,5 +1,5 @@ /** - * TestEditor — a real Editor with a mock NAPI backend for testing. + * TestEditor — a real Editor with a NAPI backend for testing. * * Usage: * const editor = new TestEditor(); @@ -10,10 +10,8 @@ */ import type { GlyphHandle } from "@shared/bridge/BridgeApi"; -import type { PointId } from "@shift/types"; -import type { Point2D } from "@shift/geo"; -import type { Glyph, GlyphInstance, GlyphInstanceEdit } from "@/lib/model/Glyph"; import { Editor } from "@/lib/editor/Editor"; +import type { GlyphInstance, GlyphSource } from "@/lib/model/Glyph"; import type { ToolName } from "@/lib/tools/core"; import { registerBuiltInTools } from "@/lib/tools/tools"; import { createBridge } from "@shift/bridge"; @@ -51,6 +49,18 @@ export class TestEditor extends Editor { return this.#clipboard.buffer; } + get pointCount(): number { + return this.activeGlyphSource?.allPoints.length ?? 0; + } + + get currentEdit(): GlyphSource | null { + return this.activeGlyphSource; + } + + get currentGlyphInstance(): GlyphInstance | null { + return this.glyphInstance; + } + startSession(handle: GlyphHandle = { name: "A", unicode: 65 }): this { if (!this.font.loaded) { this.loadFont(MUTATORSANS_DESIGNSPACE); @@ -113,30 +123,4 @@ export class TestEditor extends Editor { this.setActiveTool(name); return this; } - - get currentGlyph(): Glyph | null { - return this.glyph.peek(); - } - - get currentGlyphInstance(): GlyphInstance | null { - return this.glyphInstance; - } - - get currentEdit(): GlyphInstanceEdit | null { - return this.glyphInstance?.edit ?? null; - } - - get pointCount(): number { - return this.currentGlyphInstance?.geometry.allPoints.length ?? 0; - } - - getPointPosition(pointId: PointId): Point2D | null { - const geometry = this.currentGlyphInstance?.geometry; - if (!geometry) return null; - - const point = geometry.point(pointId); - if (!point) return null; - - return { x: point.x, y: point.y }; - } } diff --git a/crates/shift-backends/Cargo.toml b/crates/shift-backends/Cargo.toml index 0574d987..93ef6795 100644 --- a/crates/shift-backends/Cargo.toml +++ b/crates/shift-backends/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT OR Apache-2.0" crate-type = ["rlib"] [dependencies] -shift-ir = { workspace = true } +shift-font = { workspace = true } norad = "0.16.0" skrifa = "0.32.0" diff --git a/crates/shift-backends/docs/DOCS.md b/crates/shift-backends/docs/DOCS.md index d1d32120..8c76b3d7 100644 --- a/crates/shift-backends/docs/DOCS.md +++ b/crates/shift-backends/docs/DOCS.md @@ -4,7 +4,7 @@ Font format backends that convert between on-disk font files and the `Font` IR u ## Architecture Invariants -**Architecture Invariant:** All backends convert to/from `Font` (the shift-ir representation), never exposing format-specific types (norad, glyphs-reader) to callers. WHY: The rest of the editor operates on a single IR; leaking format types would couple the editor to specific file formats. +**Architecture Invariant:** All backends convert to/from `Font` (the shift-font representation), never exposing format-specific types (norad, glyphs-reader) to callers. WHY: The rest of the editor operates on a single IR; leaking format types would couple the editor to specific file formats. **Architecture Invariant:** `FontReader` and `FontWriter` require `Send + Sync`. WHY: Backends are stored in `FontLoader` which lives inside the editor's shared state; they must be safe to use from multiple threads. @@ -26,11 +26,11 @@ src/ traits.rs -- FontReader, FontWriter, FontBackend trait definitions ufo/ mod.rs -- UfoBackend convenience struct combining reader+writer; round-trip tests - reader.rs -- UfoReader: norad::Font -> shift_ir::Font - writer.rs -- UfoWriter: shift_ir::Font -> norad::Font (with coordinate rounding) + reader.rs -- UfoReader: norad::Font -> shift_font::Font + writer.rs -- UfoWriter: shift_font::Font -> norad::Font (with coordinate rounding) glyphs/ mod.rs -- GlyphsReader re-export; fixture-based integration tests - reader.rs -- GlyphsReader: glyphs_reader::Font -> shift_ir::Font (read-only) + reader.rs -- GlyphsReader: glyphs_reader::Font -> shift_font::Font (read-only) ``` ## Key Types @@ -99,7 +99,7 @@ cargo test -p shift-backends loads_glyphs_package ## Related -- `Font`, `Glyph`, `GlyphLayer`, `Contour`, `PointType` -- IR types this crate converts to/from (shift-ir) +- `Font`, `Glyph`, `GlyphLayer`, `Contour`, `PointType` -- IR types this crate converts to/from (shift-font) - `FontLoader`, `FontAdaptor` -- shift-core dispatcher that selects backends by file extension - `KerningData`, `KerningSide`, `KerningPair` -- IR kerning types that backends populate - `FeatureData` -- IR feature storage, populated from `features.fea` or Glyphs feature snippets diff --git a/crates/shift-backends/src/binary/mod.rs b/crates/shift-backends/src/binary/mod.rs index e27545c3..bcb8530c 100644 --- a/crates/shift-backends/src/binary/mod.rs +++ b/crates/shift-backends/src/binary/mod.rs @@ -2,7 +2,7 @@ mod reader; use crate::errors::{FormatBackendError, FormatBackendResult}; use crate::font_loader::FontAdaptor; -use shift_ir::Font; +use shift_font::Font; pub struct BytesFontAdaptor; diff --git a/crates/shift-backends/src/binary/reader.rs b/crates/shift-backends/src/binary/reader.rs index d5332e49..c3d88f48 100644 --- a/crates/shift-backends/src/binary/reader.rs +++ b/crates/shift-backends/src/binary/reader.rs @@ -1,5 +1,5 @@ use crate::errors::{FormatBackendError, FormatBackendResult}; -use shift_ir::{Contour, Font, Glyph, GlyphLayer, PointType}; +use shift_font::{Contour, Font, Glyph, GlyphLayer, PointType}; use skrifa::{ outline::{DrawSettings, OutlinePen}, prelude::{LocationRef, Size}, diff --git a/crates/shift-backends/src/designspace/mod.rs b/crates/shift-backends/src/designspace/mod.rs index 684587b5..01ce9fd4 100644 --- a/crates/shift-backends/src/designspace/mod.rs +++ b/crates/shift-backends/src/designspace/mod.rs @@ -10,7 +10,7 @@ pub use writer::DesignspaceWriter; mod tests { use super::*; use crate::traits::{FontReader, FontWriter}; - use shift_ir::{Contour, Font, Glyph, GlyphLayer, PointType}; + use shift_font::{Contour, Font, Glyph, GlyphLayer, PointType}; use std::fs; fn test_font() -> Font { diff --git a/crates/shift-backends/src/designspace/reader.rs b/crates/shift-backends/src/designspace/reader.rs index bab3cb09..ed345831 100644 --- a/crates/shift-backends/src/designspace/reader.rs +++ b/crates/shift-backends/src/designspace/reader.rs @@ -5,7 +5,7 @@ use crate::ufo::UfoReader; use norad::designspace::DesignSpaceDocument; use quick_xml::events::{BytesStart, Event}; use quick_xml::Reader; -use shift_ir::{Axis, Font, Layer, LayerId, Location, Source}; +use shift_font::{Axis, Font, Layer, LayerId, Location, Source}; use std::collections::HashMap; use std::fs; use std::path::Path; diff --git a/crates/shift-backends/src/designspace/writer.rs b/crates/shift-backends/src/designspace/writer.rs index 689eae9f..db762cce 100644 --- a/crates/shift-backends/src/designspace/writer.rs +++ b/crates/shift-backends/src/designspace/writer.rs @@ -5,7 +5,7 @@ use crate::ufo::UfoWriter; use norad::designspace::{Axis as DsAxis, DesignSpaceDocument, Dimension, Source as DsSource}; use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, Event}; use quick_xml::Writer; -use shift_ir::{Axis, Font, Location, Source}; +use shift_font::{Axis, Font, Location, Source}; use std::fs; use std::path::Path; diff --git a/crates/shift-backends/src/export.rs b/crates/shift-backends/src/export.rs index 049ac70c..5e63afe5 100644 --- a/crates/shift-backends/src/export.rs +++ b/crates/shift-backends/src/export.rs @@ -144,7 +144,7 @@ fn path_to_str<'a>(path: &'a Path, label: &'static str) -> Result<&'a str, Expor #[cfg(test)] mod tests { use super::*; - use shift_ir::{Contour, Font, Glyph, GlyphLayer, PointType}; + use shift_font::{Contour, Font, Glyph, GlyphLayer, PointType}; use skrifa::{FontRef, MetadataProvider}; fn simple_font() -> Font { diff --git a/crates/shift-backends/src/font_loader.rs b/crates/shift-backends/src/font_loader.rs index 512af95e..35257379 100644 --- a/crates/shift-backends/src/font_loader.rs +++ b/crates/shift-backends/src/font_loader.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::path::Path; -use shift_ir::Font; +use shift_font::Font; use crate::designspace::{DesignspaceReader, DesignspaceWriter}; use crate::errors::{BackendError, BackendResult, FormatBackendError, FormatBackendResult}; diff --git a/crates/shift-backends/src/glyphs/reader.rs b/crates/shift-backends/src/glyphs/reader.rs index 27a7b363..36a973e1 100644 --- a/crates/shift-backends/src/glyphs/reader.rs +++ b/crates/shift-backends/src/glyphs/reader.rs @@ -1,5 +1,5 @@ use glyphs_reader::{FeatureSnippet, Font as GlyphsFont, NodeType, Shape}; -use shift_ir::{ +use shift_font::{ Anchor, Axis, Component, Contour, FeatureData, Font, Glyph, GlyphLayer, KerningData, KerningPair, KerningSide, Layer, Location, Source, Transform, }; @@ -21,15 +21,15 @@ impl GlyphsReader { Self } - fn convert_node_type(node_type: NodeType) -> (shift_ir::PointType, bool) { + fn convert_node_type(node_type: NodeType) -> (shift_font::PointType, bool) { match node_type { - NodeType::Line => (shift_ir::PointType::OnCurve, false), - NodeType::LineSmooth => (shift_ir::PointType::OnCurve, true), - NodeType::OffCurve => (shift_ir::PointType::OffCurve, false), - NodeType::Curve => (shift_ir::PointType::OnCurve, false), - NodeType::CurveSmooth => (shift_ir::PointType::OnCurve, true), - NodeType::QCurve => (shift_ir::PointType::QCurve, false), - NodeType::QCurveSmooth => (shift_ir::PointType::QCurve, true), + NodeType::Line => (shift_font::PointType::OnCurve, false), + NodeType::LineSmooth => (shift_font::PointType::OnCurve, true), + NodeType::OffCurve => (shift_font::PointType::OffCurve, false), + NodeType::Curve => (shift_font::PointType::OnCurve, false), + NodeType::CurveSmooth => (shift_font::PointType::OnCurve, true), + NodeType::QCurve => (shift_font::PointType::QCurve, false), + NodeType::QCurveSmooth => (shift_font::PointType::QCurve, true), } } @@ -79,7 +79,7 @@ impl GlyphsReader { } } - // shift-ir currently stores static kerning, so we load kerning for default master. + // shift-font currently stores static kerning, so we load kerning for default master. let Some(default_master) = font.masters.get(font.default_master_idx) else { return kerning; }; diff --git a/crates/shift-backends/src/traits.rs b/crates/shift-backends/src/traits.rs index 7e6d7aea..e934a0f6 100644 --- a/crates/shift-backends/src/traits.rs +++ b/crates/shift-backends/src/traits.rs @@ -1,5 +1,5 @@ use crate::errors::FormatBackendResult; -use shift_ir::{ +use shift_font::{ Axis, FeatureData, Font, FontMetadata, FontMetrics, Glyph, GlyphName, Guideline, KerningData, Layer, LayerId, LibData, Source, }; diff --git a/crates/shift-backends/src/ufo/mod.rs b/crates/shift-backends/src/ufo/mod.rs index 5b336070..2776c4e1 100644 --- a/crates/shift-backends/src/ufo/mod.rs +++ b/crates/shift-backends/src/ufo/mod.rs @@ -6,7 +6,7 @@ pub use writer::UfoWriter; use crate::traits::{FontReader, FontWriter}; use crate::FormatBackendResult; -use shift_ir::Font; +use shift_font::Font; pub struct UfoBackend; @@ -25,7 +25,7 @@ impl FontWriter for UfoBackend { #[cfg(test)] mod tests { use super::*; - use shift_ir::{Contour, Glyph, GlyphLayer, PointType}; + use shift_font::{Contour, Glyph, GlyphLayer, PointType}; use std::fs; fn create_test_font() -> Font { diff --git a/crates/shift-backends/src/ufo/reader.rs b/crates/shift-backends/src/ufo/reader.rs index 685775ac..36dd0564 100644 --- a/crates/shift-backends/src/ufo/reader.rs +++ b/crates/shift-backends/src/ufo/reader.rs @@ -1,7 +1,7 @@ use crate::errors::{FormatBackendError, FormatBackendResult}; use crate::traits::FontReader; use norad::{Font as NoradFont, Line}; -use shift_ir::{ +use shift_font::{ Anchor, Component, Contour, FeatureData, Font, Glyph, GlyphLayer, Guideline, KerningData, KerningPair, KerningSide, Layer, LibData, LibValue, PointType, Transform, }; @@ -100,7 +100,7 @@ impl UfoReader { fn convert_glyph_layer( norad_glyph: &norad::Glyph, - layer_id: shift_ir::LayerId, + layer_id: shift_font::LayerId, ) -> (Glyph, GlyphLayer) { let mut glyph_layer = GlyphLayer::with_width(norad_glyph.width); if norad_glyph.height != 0.0 { diff --git a/crates/shift-backends/src/ufo/writer.rs b/crates/shift-backends/src/ufo/writer.rs index d832df02..d2d9454f 100644 --- a/crates/shift-backends/src/ufo/writer.rs +++ b/crates/shift-backends/src/ufo/writer.rs @@ -1,7 +1,7 @@ use crate::errors::{FormatBackendError, FormatBackendResult}; use crate::traits::{FontView, FontWriter}; use norad::{Font as NoradFont, Glyph as NoradGlyph, Line, Name}; -use shift_ir::{ +use shift_font::{ Contour, Font, Glyph, GlyphLayer, Guideline, KerningSide, LibData, LibValue, Point, PointType, }; use std::path::Path; @@ -76,7 +76,7 @@ impl UfoWriter { norad::Contour::new(points, None) } - fn convert_component(component: &shift_ir::Component) -> norad::Component { + fn convert_component(component: &shift_font::Component) -> norad::Component { let matrix = component.matrix(); norad::Component::new( Name::new(component.base_glyph()).unwrap(), @@ -92,7 +92,7 @@ impl UfoWriter { ) } - fn convert_anchor(anchor: &shift_ir::Anchor) -> norad::Anchor { + fn convert_anchor(anchor: &shift_font::Anchor) -> norad::Anchor { norad::Anchor::new( anchor.x().ufo_round(), anchor.y().ufo_round(), diff --git a/crates/shift-backends/tests/export.rs b/crates/shift-backends/tests/export.rs index 3afceef3..8f8981d8 100644 --- a/crates/shift-backends/tests/export.rs +++ b/crates/shift-backends/tests/export.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use shift_backends::font_loader::FontLoader; use shift_backends::{ExportFormat, FontExportRequest, FontExporter}; -use shift_ir::{Font, Glyph, GlyphLayer}; +use shift_font::{Font, Glyph, GlyphLayer}; fn fixtures_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/shift-backends/tests/loading.rs b/crates/shift-backends/tests/loading.rs index 44bb9f35..8374d36f 100644 --- a/crates/shift-backends/tests/loading.rs +++ b/crates/shift-backends/tests/loading.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use shift_backends::font_loader::FontLoader; -use shift_ir::{Font, Glyph, GlyphLayer, PointType}; +use shift_font::{Font, Glyph, GlyphLayer, PointType}; fn fixtures_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/shift-backends/tests/round_trip/ufo.rs b/crates/shift-backends/tests/round_trip/ufo.rs index 0f74e990..96813938 100644 --- a/crates/shift-backends/tests/round_trip/ufo.rs +++ b/crates/shift-backends/tests/round_trip/ufo.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use shift_backends::font_loader::FontLoader; -use shift_ir::{Anchor, Font, Glyph, GlyphLayer}; +use shift_font::{Anchor, Font, Glyph, GlyphLayer}; fn fixtures_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -44,7 +44,7 @@ fn main_layer(glyph: &Glyph) -> &GlyphLayer { .expect("glyph should have at least one layer") } -fn sorted_contours(layer: &GlyphLayer) -> Vec<&shift_ir::Contour> { +fn sorted_contours(layer: &GlyphLayer) -> Vec<&shift_font::Contour> { let mut contours: Vec<_> = layer.contours_iter().collect(); contours.sort_by(|a, b| { let a_first = a.points().first().map(|point| { diff --git a/crates/shift-bridge/Cargo.toml b/crates/shift-bridge/Cargo.toml index a67b2bf8..62c9155b 100644 --- a/crates/shift-bridge/Cargo.toml +++ b/crates/shift-bridge/Cargo.toml @@ -9,9 +9,8 @@ license = "MIT" crate-type = ["cdylib"] [dependencies] -shift-edit = { workspace = true } +shift-font = { workspace = true } shift-wire = { workspace = true, features = ["napi"] } -shift-ir = { workspace = true } shift-backends = { workspace = true } napi = { version = "=3.8.6", default-features = false, features = ["napi6"] } diff --git a/crates/shift-bridge/__test__/index.spec.mjs b/crates/shift-bridge/__test__/index.spec.mjs index de4121ef..5b005718 100644 --- a/crates/shift-bridge/__test__/index.spec.mjs +++ b/crates/shift-bridge/__test__/index.spec.mjs @@ -18,6 +18,13 @@ describe("Bridge", () => { return bridge.getSources()[0].id; } + function defaultLayerRef(name = "A", unicode = 65) { + return { + glyphHandle: { name, unicode }, + layerId: bridge.getSources()[0].layerId, + }; + } + it("starts with default committed font metadata", () => { expect(bridge.getMetadata()).toMatchObject({ familyName: "Untitled Font", @@ -38,33 +45,25 @@ describe("Bridge", () => { expect(bridge.getGlyphs()).toEqual([]); }); - it("commits a new glyph when the edit session ends", () => { - bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); - expect(bridge.hasEditSession()).toBe(true); - expect(bridge.getEditingGlyphName()).toBe("A"); - expect(bridge.getEditingSourceId()).toBe(defaultSourceId()); - expect(bridge.getEditingUnicode()).toBe(65); + it("creates a new glyph through an explicit layer edit", () => { + bridge.setXAdvance(defaultLayerRef(), 500); - bridge.endEditSession(); - - expect(bridge.hasEditSession()).toBe(false); expect(bridge.getGlyphs()).toEqual([ { name: "A", unicodes: [65], componentBaseGlyphNames: [] }, ]); }); - it("saves the active edit snapshot without ending the session", async () => { + it("saves direct glyph layer edits", async () => { const tempDir = mkdtempSync(join(tmpdir(), "shift-bridge-save-")); try { - bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); - const contourId = bridge.addContour().changed.contourIds[0]; - bridge.addPoint(contourId, 10, 20, "onCurve", false); + const glyphRef = defaultLayerRef(); + const contourId = bridge.addContour(glyphRef).changed.contourIds[0]; + bridge.addPoint(glyphRef, contourId, 10, 20, "onCurve", false); const outputPath = join(tempDir, "output.ufo"); const savedVersion = await bridge.saveFont(outputPath); expect(savedVersion).toBe(2); - expect(bridge.hasEditSession()).toBe(true); expect(bridge.getPersistedVersion()).toBe(2); expect(bridge.isDirty()).toBe(false); expect(existsSync(outputPath)).toBe(true); @@ -82,8 +81,7 @@ describe("Bridge", () => { it("records the persisted version when an async save completes", async () => { const tempDir = mkdtempSync(join(tmpdir(), "shift-bridge-async-save-")); try { - bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); - bridge.addContour(); + bridge.addContour(defaultLayerRef()); const outputPath = join(tempDir, "async-output.ufo"); const savedVersion = await bridge.saveFont(outputPath); @@ -97,21 +95,12 @@ describe("Bridge", () => { } }); - it("rejects starting a second active edit session", () => { - bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); - - expect(() => - bridge.startEditSession({ name: "B", unicode: 66 }, defaultSourceId()), - ).toThrow(/edit session already active/i); - expect(bridge.getEditingGlyphName()).toBe("A"); - }); - it("adds a point to a contour and returns structure, values, and changed ids", () => { - bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); - const contourChange = bridge.addContour(); + const glyphRef = defaultLayerRef(); + const contourChange = bridge.addContour(glyphRef); const contourId = contourChange.changed.contourIds[0]; - const change = bridge.addPoint(contourId, 10, 20, "onCurve", false); + const change = bridge.addPoint(glyphRef, contourId, 10, 20, "onCurve", false); expect(change.changed.pointIds).toHaveLength(1); expect(change.structure.contours).toHaveLength(1); @@ -130,12 +119,13 @@ describe("Bridge", () => { }); it("applies point positions through the sparse typed-array hot path", () => { - bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); - const contourId = bridge.addContour().changed.contourIds[0]; - const pointId = bridge.addPoint(contourId, 10, 20, "onCurve", false).changed + const glyphRef = defaultLayerRef(); + const contourId = bridge.addContour(glyphRef).changed.contourIds[0]; + const pointId = bridge.addPoint(glyphRef, contourId, 10, 20, "onCurve", false).changed .pointIds[0]; bridge.applyPositionPatch( + glyphRef, new BigUint64Array([BigInt(pointId)]), new Float64Array([30, 40]), null, @@ -149,13 +139,14 @@ describe("Bridge", () => { expect(Array.from(state.values)).toEqual([500, 30, 40]); }); - it("restores structure and values into the active session", () => { - bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); - const contourId = bridge.addContour().changed.contourIds[0]; - const before = bridge.addPoint(contourId, 10, 20, "onCurve", false); + it("restores structure and values into a glyph layer", () => { + const glyphRef = defaultLayerRef(); + const contourId = bridge.addContour(glyphRef).changed.contourIds[0]; + const before = bridge.addPoint(glyphRef, contourId, 10, 20, "onCurve", false); const pointId = before.changed.pointIds[0]; const change = bridge.restoreState( + glyphRef, before.structure, new Float64Array([700, 90, 120]), ); @@ -165,14 +156,17 @@ describe("Bridge", () => { }); it("surfaces typed bridge errors at the NAPI boundary", () => { - expect(() => bridge.addContour()).toThrow(/active edit/i); + expect(() => + bridge.addContour({ glyphHandle: { name: "A", unicode: 65 }, layerId: "not-a-layer" }), + ).toThrow(/layer ID/i); - bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); + const glyphRef = defaultLayerRef(); expect(() => - bridge.addPoint("not-a-contour", 10, 20, "onCurve", false), + bridge.addPoint(glyphRef, "not-a-contour", 10, 20, "onCurve", false), ).toThrow(/contour ID/i); expect(() => bridge.applyPositionPatch( + glyphRef, new BigUint64Array([1n]), new Float64Array([10]), null, diff --git a/crates/shift-bridge/docs/DOCS.md b/crates/shift-bridge/docs/DOCS.md index bdca33bf..c7bcad24 100644 --- a/crates/shift-bridge/docs/DOCS.md +++ b/crates/shift-bridge/docs/DOCS.md @@ -1,10 +1,10 @@ # shift-bridge -NAPI bindings that expose the Rust font/editing engine to Node.js and Electron as a `Bridge` class. +NAPI bindings that expose the Rust font engine to Node.js and Electron as a `Bridge` class. ## Architecture Invariants -**Architecture Invariant:** Only one `ActiveEdit` may be active at a time. Starting a second edit session returns a typed bridge error. **WHY:** The bridge owns one mutable edit surface and saves use an overlay snapshot of that active glyph. +**Architecture Invariant:** The bridge does not own hidden edit sessions. Mutation methods receive an explicit `GlyphLayerRef`, resolve the target glyph layer, and mutate the model object in `shift-font`. **WHY:** Renderer selection state stays in TypeScript, while Rust remains the authoritative model and ID allocator. **Architecture Invariant:** Public bridge DTOs live in `shift-wire`; NAPI-specific wrappers live under `shift-wire::bridges::napi`. **WHY:** Wire shapes remain independent of the native module implementation, while NAPI can still return efficient types such as `Float64Array`. @@ -12,7 +12,7 @@ NAPI bindings that expose the Rust font/editing engine to Node.js and Electron a **Architecture Invariant:** Bulk position updates use `Float64Array` values plus typed node descriptors. **WHY:** Drag updates keep the hot path in flat numeric buffers while IDs remain branded strings at the API boundary. -**Architecture Invariant:** Save uses a clone/COW `FontSaveSnapshot` plus an active glyph overlay. **WHY:** Async save can run from a stable view of the font without ending or mutating the current edit session. +**Architecture Invariant:** Save uses a clone/COW `FontSaveSnapshot` of the current font. **WHY:** Async save can run from a stable view without coupling persistence to renderer focus state. ## Codemap @@ -20,28 +20,28 @@ NAPI bindings that expose the Rust font/editing engine to Node.js and Electron a crates/shift-bridge/ src/ lib.rs -- crate root - bridge.rs -- `Bridge` NAPI class, session lifecycle, font reads, save task + bridge.rs -- `Bridge` NAPI class, font reads, mutations, save task errors.rs -- bridge error type and NAPI mapping input.rs -- boundary parsing/adaptation helpers - Cargo.toml -- cdylib crate; depends on shift-edit, shift-wire, shift-backends, napi + Cargo.toml -- cdylib crate; depends on shift-font, shift-wire, shift-backends, napi ``` ## Key Types -- `Bridge` -- the exported `#[napi]` class holding the committed `Font`, optional `ActiveEdit`, and document versions. -- `ActiveEdit` -- active glyph/session/layer bundle used while editing. -- `FontSaveSnapshot` -- clone/COW save view with an optional active glyph override. +- `Bridge` -- the exported `#[napi]` class holding the current `Font` and document versions. +- `GlyphLayerRef` -- boundary identity for the glyph layer being mutated. +- `FontSaveSnapshot` -- clone/COW save view of the current font. - `SaveFontTask` -- NAPI `Task` implementation for async font saving. - `BridgeError` -- typed bridge error enum converted once at the NAPI boundary. -- `GlyphStructureChange` / `GlyphValueChange` -- canonical wire DTOs returned by edit mutations. +- `GlyphStructureChange` / `GlyphValueChange` -- canonical wire DTOs returned by mutations. - `NapiGlyphStructureChange` / `NapiGlyphValueChange` -- NAPI adapters for those DTOs. -## Session Lifecycle +## How it works -1. JS calls `startEditSession(GlyphHandle)`. -2. `Bridge` creates or finds the glyph, removes the editable layer into an `EditSession`, and stores it in `active_edit`. -3. Mutation methods parse boundary inputs, borrow the active session, call `EditSession`, then return a wire change object. -4. `endEditSession` commits the session layer back into the glyph and stores it in the font. +1. The renderer chooses the active glyph/source and builds a `GlyphLayerRef`. +2. JS calls a mutation such as `closeContour(glyphRef, contourId)`. +3. `Bridge` parses boundary strings, resolves or creates the target glyph layer, and calls the matching `GlyphLayer` method from `shift-font`. +4. The bridge returns a `shift-wire` change DTO and bumps the live version. 5. `saveFont(path)` creates a `FontSaveSnapshot` and saves asynchronously through `shift-backends`. ## Type Boundary @@ -54,16 +54,16 @@ crates/shift-bridge/ ### Adding a new mutation method -1. Add the domain operation to `EditSession` in `shift-edit`. +1. Add the domain operation to the relevant model object in `shift-font`. 2. Add or reuse a canonical DTO in `shift-wire`. 3. Add a NAPI adapter in `shift-wire::bridges::napi` only if NAPI needs a different representation. 4. Add the `#[napi]` method on `Bridge`. -5. Parse string IDs through `input.rs`, call the session, then return the appropriate wire change. +5. Accept `GlyphLayerRef` when the operation targets glyph outline state, parse string IDs through `input.rs`, call the model method, then return the appropriate wire change. 6. Run `cargo check -p shift-bridge` and rebuild the native module before regenerating bridge types. ### Adding a new read-only query -1. Prefer committed-font reads unless the method is explicitly about the active edit session. +1. Prefer committed font reads unless the method is explicitly asking for the currently focused renderer source. 2. Return native NAPI DTOs rather than serialized JSON. 3. Keep editor/rendering concerns out of Rust; TypeScript owns canvas-specific interpretation. @@ -79,8 +79,7 @@ pnpm generate:bridge-types ## Related -- `shift-edit` -- edit session and glyph mutation logic. -- `shift-ir` -- font/glyph/layer data model. +- `shift-font` -- font/glyph/layer data model and model-level mutation logic. - `shift-wire` -- canonical bridge DTOs and NAPI adapters. - `shift-backends` -- font loading/saving. - `packages/types/src/bridge` -- generated TypeScript bridge facade. diff --git a/crates/shift-bridge/index.d.ts b/crates/shift-bridge/index.d.ts index 076bcd65..10e22ed0 100644 --- a/crates/shift-bridge/index.d.ts +++ b/crates/shift-bridge/index.d.ts @@ -26,31 +26,25 @@ export declare class Bridge { isVariable(): boolean getAxes(): Array getSources(): Array - startEditSession(glyphHandle: GlyphHandle, sourceId: SourceId): void getPersistedVersion(): number isDirty(): boolean - endEditSession(): void - hasEditSession(): boolean - getEditingUnicode(): Unicode | null - getEditingGlyphName(): GlyphName | null - getEditingSourceId(): SourceId | null - setXAdvance(width: number): NapiGlyphValueChange - translateLayer(dx: number, dy: number): NapiGlyphValueChange - addPoint(contourId: ContourId, x: number, y: number, pointType: NapiPointType, smooth: boolean): NapiGlyphStructureChange - insertPointBefore(beforePointId: PointId, x: number, y: number, pointType: NapiPointType, smooth: boolean): NapiGlyphStructureChange - addContour(): NapiGlyphStructureChange - openContour(contourId: ContourId): NapiGlyphStructureChange - closeContour(contourId: ContourId): NapiGlyphStructureChange - reverseContour(contourId: ContourId): NapiGlyphStructureChange - applyBooleanOp(contourIdA: ContourId, contourIdB: ContourId, operation: string): NapiGlyphStructureChange - removePoints(pointIds: Array): NapiGlyphStructureChange - toggleSmooth(pointId: PointId): NapiGlyphStructureChange + setXAdvance(glyphRef: GlyphLayerRef, width: number): NapiGlyphValueChange + translateLayer(glyphRef: GlyphLayerRef, dx: number, dy: number): NapiGlyphValueChange + addPoint(glyphRef: GlyphLayerRef, contourId: ContourId, x: number, y: number, pointType: NapiPointType, smooth: boolean): NapiGlyphStructureChange + insertPointBefore(glyphRef: GlyphLayerRef, beforePointId: PointId, x: number, y: number, pointType: NapiPointType, smooth: boolean): NapiGlyphStructureChange + addContour(glyphRef: GlyphLayerRef): NapiGlyphStructureChange + openContour(glyphRef: GlyphLayerRef, contourId: ContourId): NapiGlyphStructureChange + closeContour(glyphRef: GlyphLayerRef, contourId: ContourId): NapiGlyphStructureChange + reverseContour(glyphRef: GlyphLayerRef, contourId: ContourId): NapiGlyphStructureChange + applyBooleanOp(glyphRef: GlyphLayerRef, contourIdA: ContourId, contourIdB: ContourId, operation: string): NapiGlyphStructureChange + removePoints(glyphRef: GlyphLayerRef, pointIds: Array): NapiGlyphStructureChange + toggleSmooth(glyphRef: GlyphLayerRef, pointId: PointId): NapiGlyphStructureChange /** * Bulk position sync. IDs use BigUint64Array to avoid lossy float packing. * Coords are interleaved [x0, y0, x1, y1, ...]. */ - applyPositionPatch(pointIds?: BigUint64Array | undefined | null, pointCoords?: Float64Array | undefined | null, anchorIds?: BigUint64Array | undefined | null, anchorCoords?: Float64Array | undefined | null): void - restoreState(structure: NapiGlyphStructure, values: Float64Array): NapiGlyphStructureChange + applyPositionPatch(glyphRef: GlyphLayerRef, pointIds?: BigUint64Array | undefined | null, pointCoords?: Float64Array | undefined | null, anchorIds?: BigUint64Array | undefined | null, anchorCoords?: Float64Array | undefined | null): void + restoreState(glyphRef: GlyphLayerRef, structure: NapiGlyphStructure, values: Float64Array): NapiGlyphStructureChange } export interface GlyphHandle { @@ -58,6 +52,11 @@ export interface GlyphHandle { unicode?: Unicode } +export interface GlyphLayerRef { + glyphHandle: GlyphHandle + layerId: LayerId +} + export interface NapiFontExportRequest { path: string format: string diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index 84a8c58f..705409ee 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -8,16 +8,17 @@ use shift_backends::{ font_loader::FontLoader, ExportFormat, FontExportRequest, FontExportResult, FontExporter, FontView, }; -use shift_edit::{ - edit_session::{BulkNodePositionUpdates, EditSession}, - interpolation::{build_glyph_variation_data, build_masters, GlyphVariationBuild}, - BooleanOp, ContourId, Font, Glyph, GlyphLayer, GlyphName, LayerId, PointId, SourceId, +use shift_font::{ + BooleanOp, BulkNodePositionUpdates, ContourId, Font, Glyph, GlyphLayer, GlyphName, LayerId, + PointId, SourceId, }; use shift_wire::{ bridges::napi::{ NapiAxis, NapiFontMetadata, NapiFontMetrics, NapiGlyphRecord, NapiGlyphState, NapiGlyphStructure, NapiGlyphStructureChange, NapiGlyphValueChange, NapiPointType, NapiSource, }, + interpolation::{build_glyph_variation_data, build_masters, GlyphVariationBuild}, + state::apply_state_to_layer, Axis, FontMetadata, FontMetrics, GlyphChangedEntities, GlyphRecord, GlyphState, GlyphStructure, GlyphStructureChange, GlyphValueChange, Source, }; @@ -35,6 +36,14 @@ pub struct GlyphHandle { pub unicode: Option, } +#[napi(object)] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GlyphLayerRef { + pub glyph_handle: GlyphHandle, + #[napi(ts_type = "LayerId")] + pub layer_id: String, +} + #[napi(object)] #[derive(Clone, Debug)] pub struct NapiFontExportRequest { @@ -161,23 +170,23 @@ impl FontSaveSnapshot { } impl FontView for FontSaveSnapshot { - fn metadata(&self) -> &shift_ir::FontMetadata { + fn metadata(&self) -> &shift_font::FontMetadata { self.font.metadata() } - fn metrics(&self) -> &shift_ir::FontMetrics { + fn metrics(&self) -> &shift_font::FontMetrics { self.font.metrics() } - fn axes(&self) -> &[shift_ir::Axis] { + fn axes(&self) -> &[shift_font::Axis] { self.font.axes() } - fn sources(&self) -> &[shift_ir::Source] { + fn sources(&self) -> &[shift_font::Source] { self.font.sources() } - fn layers(&self) -> Vec<(LayerId, &shift_ir::Layer)> { + fn layers(&self) -> Vec<(LayerId, &shift_font::Layer)> { self .font .layers() @@ -219,19 +228,19 @@ impl FontView for FontSaveSnapshot { self.font.glyph(name) } - fn kerning(&self) -> &shift_ir::KerningData { + fn kerning(&self) -> &shift_font::KerningData { self.font.kerning() } - fn features(&self) -> &shift_ir::FeatureData { + fn features(&self) -> &shift_font::FeatureData { self.font.features() } - fn guidelines(&self) -> &[shift_ir::Guideline] { + fn guidelines(&self) -> &[shift_font::Guideline] { self.font.guidelines() } - fn lib(&self) -> &shift_ir::LibData { + fn lib(&self) -> &shift_font::LibData { self.font.lib() } @@ -285,92 +294,8 @@ impl Task for ExportFontTask { } } -pub struct ActiveEdit { - session: EditSession, - glyph: Glyph, - source_id: SourceId, - layer_id: LayerId, - dirty: bool, -} - -impl ActiveEdit { - fn new(session: EditSession, glyph: Glyph, source_id: SourceId, layer_id: LayerId) -> Self { - Self { - session, - glyph, - source_id, - layer_id, - dirty: false, - } - } - - fn from_glyph( - glyph: Glyph, - source_id: SourceId, - layer_id: LayerId, - unicode_hint: Option, - ) -> Self { - let unicode = glyph.primary_unicode().or(unicode_hint).unwrap_or(0); - let layer = glyph - .layer(layer_id) - .cloned() - .unwrap_or_else(|| GlyphLayer::with_width(500.0)); - let session = EditSession::new(glyph.name().to_string(), unicode, layer); - - Self::new(session, glyph, source_id, layer_id) - } - - fn session(&self) -> &EditSession { - &self.session - } - - fn session_mut(&mut self) -> &mut EditSession { - &mut self.session - } - - fn mark_dirty(&mut self) { - self.dirty = true; - } - - fn is_dirty(&self) -> bool { - self.dirty - } - - fn source_id(&self) -> SourceId { - self.source_id - } - - fn glyph_with_session_layer(&self) -> Glyph { - let mut glyph = self.glyph.clone(); - glyph.set_layer(self.layer_id, self.session.layer().clone()); - if self.session.unicode() != 0 { - glyph.add_unicode(self.session.unicode()); - } - glyph - } - - fn finish(self) -> Glyph { - let Self { - session, - mut glyph, - layer_id, - .. - } = self; - - let session_unicode = session.unicode(); - let layer = session.into_layer(); - glyph.set_layer(layer_id, layer); - if session_unicode != 0 { - glyph.add_unicode(session_unicode); - } - - glyph - } -} - #[napi] pub struct Bridge { - active_edit: Option, font_loader: FontLoader, font: Font, live_version: DocumentVersion, @@ -383,7 +308,6 @@ impl Bridge { pub fn new() -> Self { Self { font_loader: FontLoader::new(), - active_edit: None, font: Font::default(), live_version: DocumentVersion::default(), persisted_version: Arc::new(AtomicU64::new(0)), @@ -393,7 +317,6 @@ impl Bridge { #[napi] pub fn create_font(&mut self) { self.font = Font::new(); - self.active_edit = None; self.live_version = DocumentVersion::default(); self.persisted_version = Arc::new(AtomicU64::new(0)); } @@ -401,7 +324,6 @@ impl Bridge { #[napi] pub fn load_font(&mut self, path: String) -> errors::Result<()> { self.font = self.font_loader.read_font(&path)?; - self.active_edit = None; self.live_version = DocumentVersion::default(); self.persisted_version = Arc::new(AtomicU64::new(0)); Ok(()) @@ -476,13 +398,6 @@ impl Bridge { return Ok(()); } - if self.active_edit.is_some() { - return Err(BridgeError::InvalidInput { - kind: "glyph identity update", - value: "cannot update while an edit session is active".to_string(), - }); - } - if name.is_empty() { return Err(BridgeError::InvalidInput { kind: "glyph name", @@ -512,7 +427,7 @@ impl Bridge { glyph.set_name(glyph_name); glyph.set_unicodes(unicodes); self.font.put_glyph(glyph); - self.mark_committed_changed(); + self.mark_font_changed(); Ok(()) } @@ -614,81 +529,41 @@ impl Bridge { }) } - fn start_edit_session_for_name( - &mut self, - glyph_name: &str, - source_id: SourceId, - unicode_hint: Option, - ) -> errors::Result<()> { - if self.active_edit.is_some() { - return Err(BridgeError::ActiveEditAlreadyExists); - } - - let layer_id = self.source_layer_id(source_id)?; - let glyph = self - .font - .glyph(glyph_name) - .cloned() - .unwrap_or_else(|| Glyph::new(glyph_name.to_string())); - - self.active_edit = Some(ActiveEdit::from_glyph( - glyph, - source_id, - layer_id, - unicode_hint, - )); - - Ok(()) - } - - #[napi] - pub fn start_edit_session( - &mut self, - glyph_handle: GlyphHandle, - #[napi(ts_arg_type = "SourceId")] source_id: String, - ) -> errors::Result<()> { - let source_id = parse::(&source_id)?; - self.start_edit_session_for_name(&glyph_handle.name, source_id, glyph_handle.unicode) - } - - fn active_edit(&self) -> BridgeResult<&ActiveEdit> { - self.active_edit.as_ref().ok_or(BridgeError::NoActiveEdit) + fn save_snapshot(&self) -> FontSaveSnapshot { + FontSaveSnapshot::new(self.live_version(), self.font.clone(), None) } - fn active_edit_mut(&mut self) -> BridgeResult<&mut ActiveEdit> { - self.active_edit.as_mut().ok_or(BridgeError::NoActiveEdit) + fn glyph_for_read(&self, glyph_name: &str) -> Option { + self.font.glyph(glyph_name).cloned() } - fn take_active_edit(&mut self) -> BridgeResult { - self.active_edit.take().ok_or(BridgeError::NoActiveEdit) - } + fn editable_layer_mut(&mut self, glyph_ref: GlyphLayerRef) -> BridgeResult<&mut GlyphLayer> { + let layer_id = parse::(&glyph_ref.layer_id)?; + let glyph_name = glyph_ref.glyph_handle.name; + let unicode = glyph_ref.glyph_handle.unicode; - fn active_session(&self) -> BridgeResult<&EditSession> { - Ok(self.active_edit()?.session()) - } + if self.font.glyph(&glyph_name).is_none() { + let mut glyph = Glyph::new(glyph_name.clone()); + if let Some(unicode) = unicode { + glyph.add_unicode(unicode); + } + glyph.set_layer(layer_id, GlyphLayer::with_width(500.0)); + self.font.insert_glyph(glyph); + } - fn active_session_mut(&mut self) -> BridgeResult<&mut EditSession> { - Ok(self.active_edit_mut()?.session_mut()) - } + let glyph = self + .font + .glyph_mut(&glyph_name) + .ok_or_else(|| BridgeError::InvalidInput { + kind: "glyph name", + value: glyph_name.clone(), + })?; - fn save_snapshot(&self) -> FontSaveSnapshot { - FontSaveSnapshot::new( - self.live_version(), - self.font.clone(), - self - .active_edit - .as_ref() - .map(ActiveEdit::glyph_with_session_layer), - ) - } + if let Some(unicode) = unicode { + glyph.add_unicode(unicode); + } - fn glyph_for_read(&self, glyph_name: &str) -> Option { - self - .active_edit - .as_ref() - .filter(|active_edit| active_edit.session().glyph_name() == glyph_name) - .map(ActiveEdit::glyph_with_session_layer) - .or_else(|| self.font.glyph(glyph_name).cloned()) + Ok(glyph.get_or_create_layer(layer_id)) } fn variation_build_for_glyph(&self, glyph: &Glyph) -> Option<(usize, GlyphVariationBuild)> { @@ -790,14 +665,7 @@ impl Bridge { } } - fn mark_active_edit_changed(&mut self) { - self.bump_live_version(); - if let Some(active_edit) = self.active_edit.as_mut() { - active_edit.mark_dirty(); - } - } - - fn mark_committed_changed(&mut self) { + fn mark_font_changed(&mut self) { self.bump_live_version(); } @@ -824,67 +692,42 @@ impl Bridge { } #[napi] - pub fn end_edit_session(&mut self) -> Result<()> { - let active_edit = self.take_active_edit()?; - let was_dirty = active_edit.is_dirty(); - let glyph = active_edit.finish(); - self.font.put_glyph(glyph); - if !was_dirty { - self.mark_committed_changed(); - } - - Ok(()) - } - - #[napi] - pub fn has_edit_session(&self) -> bool { - self.active_edit.is_some() - } - - #[napi(ts_return_type = "Unicode | null")] - pub fn get_editing_unicode(&self) -> Option { - self.active_session().ok().map(|session| session.unicode()) - } - - #[napi(ts_return_type = "GlyphName | null")] - pub fn get_editing_glyph_name(&self) -> Option { - self - .active_session() - .ok() - .map(|session| session.glyph_name().to_string()) - } - - #[napi(ts_return_type = "SourceId | null")] - pub fn get_editing_source_id(&self) -> Option { - self - .active_edit() - .ok() - .map(|edit| edit.source_id().to_string()) - } - - #[napi] - pub fn set_x_advance(&mut self, width: f64) -> errors::Result { - let session = self.active_session_mut()?; - session.set_x_advance(width); + pub fn set_x_advance( + &mut self, + glyph_ref: GlyphLayerRef, + width: f64, + ) -> errors::Result { + let change = { + let layer = self.editable_layer_mut(glyph_ref)?; + layer.set_x_advance(width); + GlyphValueChange::from_layer(layer, Default::default()) + }; - let change = GlyphValueChange::from_layer(session.layer(), Default::default()); - self.mark_active_edit_changed(); + self.mark_font_changed(); Ok(change.into()) } #[napi] - pub fn translate_layer(&mut self, dx: f64, dy: f64) -> errors::Result { - let session = self.active_session_mut()?; - session.translate_layer(dx, dy); + pub fn translate_layer( + &mut self, + glyph_ref: GlyphLayerRef, + dx: f64, + dy: f64, + ) -> errors::Result { + let change = { + let layer = self.editable_layer_mut(glyph_ref)?; + layer.translate_layer(dx, dy); + GlyphValueChange::from_layer(layer, Default::default()) + }; - let change = GlyphValueChange::from_layer(session.layer(), Default::default()); - self.mark_active_edit_changed(); + self.mark_font_changed(); Ok(change.into()) } #[napi] pub fn add_point( &mut self, + glyph_ref: GlyphLayerRef, #[napi(ts_arg_type = "ContourId")] contour_id: String, x: f64, y: f64, @@ -894,22 +737,24 @@ impl Bridge { let contour_id = parse::(&contour_id)?; let point_type = point_type.into(); - let session = self.active_session_mut()?; - let point_id = session.add_point_to_contour(contour_id, x, y, point_type, smooth)?; - - let changed = GlyphChangedEntities { - point_ids: vec![point_id], - ..Default::default() + let change = { + let layer = self.editable_layer_mut(glyph_ref)?; + let point_id = layer.add_point_to_contour(contour_id, x, y, point_type, smooth)?; + let changed = GlyphChangedEntities { + point_ids: vec![point_id], + ..Default::default() + }; + GlyphStructureChange::from_layer(layer, changed) }; - let change = GlyphStructureChange::from_layer(session.layer(), changed); - self.mark_active_edit_changed(); + self.mark_font_changed(); Ok(change.into()) } #[napi] pub fn insert_point_before( &mut self, + glyph_ref: GlyphLayerRef, #[napi(ts_arg_type = "PointId")] before_point_id: String, x: f64, y: f64, @@ -919,99 +764,103 @@ impl Bridge { let before_point_id = parse::(&before_point_id)?; let point_type = point_type.into(); - let session = self.active_session_mut()?; - let point_id = session.insert_point_before(before_point_id, x, y, point_type, smooth)?; - - let changed = GlyphChangedEntities { - point_ids: vec![point_id], - ..Default::default() + let change = { + let layer = self.editable_layer_mut(glyph_ref)?; + let point_id = layer.insert_point_before(before_point_id, x, y, point_type, smooth)?; + let changed = GlyphChangedEntities { + point_ids: vec![point_id], + ..Default::default() + }; + GlyphStructureChange::from_layer(layer, changed) }; - let change = GlyphStructureChange::from_layer(session.layer(), changed); - - self.mark_active_edit_changed(); + self.mark_font_changed(); Ok(change.into()) } #[napi] - pub fn add_contour(&mut self) -> Result { - let session = self.active_session_mut()?; - let contour_id = session.add_empty_contour(); - - let changed = GlyphChangedEntities { - contour_ids: vec![contour_id], - ..Default::default() + pub fn add_contour(&mut self, glyph_ref: GlyphLayerRef) -> Result { + let change = { + let layer = self.editable_layer_mut(glyph_ref)?; + let contour_id = layer.add_empty_contour(); + let changed = GlyphChangedEntities { + contour_ids: vec![contour_id], + ..Default::default() + }; + GlyphStructureChange::from_layer(layer, changed) }; - let change = GlyphStructureChange::from_layer(session.layer(), changed); - - self.mark_active_edit_changed(); + self.mark_font_changed(); Ok(change.into()) } #[napi] pub fn open_contour( &mut self, + glyph_ref: GlyphLayerRef, #[napi(ts_arg_type = "ContourId")] contour_id: String, ) -> errors::Result { let contour_id = parse::(&contour_id)?; - let session = self.active_session_mut()?; - session.open_contour(contour_id)?; - - let changed = GlyphChangedEntities { - contour_ids: vec![contour_id], - ..Default::default() + let change = { + let layer = self.editable_layer_mut(glyph_ref)?; + layer.open_contour(contour_id)?; + let changed = GlyphChangedEntities { + contour_ids: vec![contour_id], + ..Default::default() + }; + GlyphStructureChange::from_layer(layer, changed) }; - let change = GlyphStructureChange::from_layer(session.layer(), changed); - - self.mark_active_edit_changed(); + self.mark_font_changed(); Ok(change.into()) } #[napi] pub fn close_contour( &mut self, + glyph_ref: GlyphLayerRef, #[napi(ts_arg_type = "ContourId")] contour_id: String, ) -> errors::Result { let contour_id = parse::(&contour_id)?; - let session = self.active_session_mut()?; - session.close_contour(contour_id)?; - - let changed = GlyphChangedEntities { - contour_ids: vec![contour_id], - ..Default::default() + let change = { + let layer = self.editable_layer_mut(glyph_ref)?; + layer.close_contour(contour_id)?; + let changed = GlyphChangedEntities { + contour_ids: vec![contour_id], + ..Default::default() + }; + GlyphStructureChange::from_layer(layer, changed) }; - let change = GlyphStructureChange::from_layer(session.layer(), changed); - - self.mark_active_edit_changed(); + self.mark_font_changed(); Ok(change.into()) } #[napi] pub fn reverse_contour( &mut self, + glyph_ref: GlyphLayerRef, #[napi(ts_arg_type = "ContourId")] contour_id: String, ) -> errors::Result { let contour_id = parse::(&contour_id)?; - let session = self.active_session_mut()?; - session.reverse_contour(contour_id)?; - - let changed = GlyphChangedEntities { - contour_ids: vec![contour_id], - ..Default::default() + let change = { + let layer = self.editable_layer_mut(glyph_ref)?; + layer.reverse_contour(contour_id)?; + let changed = GlyphChangedEntities { + contour_ids: vec![contour_id], + ..Default::default() + }; + GlyphStructureChange::from_layer(layer, changed) }; - let change = GlyphStructureChange::from_layer(session.layer(), changed); - - self.mark_active_edit_changed(); + self.mark_font_changed(); Ok(change.into()) } #[napi] pub fn apply_boolean_op( &mut self, + glyph_ref: GlyphLayerRef, #[napi(ts_arg_type = "ContourId")] contour_id_a: String, #[napi(ts_arg_type = "ContourId")] contour_id_b: String, operation: String, @@ -1032,54 +881,58 @@ impl Bridge { } }; - let session = self.active_session_mut()?; - let created_ids = session.apply_boolean_op(cid_a, cid_b, op)?; - - let changed = GlyphChangedEntities { - contour_ids: created_ids, - ..Default::default() + let change = { + let layer = self.editable_layer_mut(glyph_ref)?; + let created_ids = layer.apply_boolean_op(cid_a, cid_b, op)?; + let changed = GlyphChangedEntities { + contour_ids: created_ids, + ..Default::default() + }; + GlyphStructureChange::from_layer(layer, changed) }; - let change = GlyphStructureChange::from_layer(session.layer(), changed); - - self.mark_active_edit_changed(); + self.mark_font_changed(); Ok(change.into()) } #[napi] pub fn remove_points( &mut self, + glyph_ref: GlyphLayerRef, #[napi(ts_arg_type = "Array")] point_ids: Vec, ) -> errors::Result { let point_ids: BridgeResult> = point_ids.iter().map(|id| parse::(id)).collect(); let point_ids = point_ids?; - let session = self.active_session_mut()?; - session.remove_points(&point_ids)?; + let change = { + let layer = self.editable_layer_mut(glyph_ref)?; + layer.remove_points(&point_ids)?; + let changed = GlyphChangedEntities::points(point_ids); + GlyphStructureChange::from_layer(layer, changed) + }; - let changed = GlyphChangedEntities::points(point_ids); - let change = GlyphStructureChange::from_layer(session.layer(), changed); - self.mark_active_edit_changed(); + self.mark_font_changed(); Ok(change.into()) } #[napi] pub fn toggle_smooth( &mut self, + glyph_ref: GlyphLayerRef, #[napi(ts_arg_type = "PointId")] point_id: String, ) -> errors::Result { let parsed_id = parse::(&point_id)?; - let session = self.active_session_mut()?; - session.toggle_smooth(parsed_id)?; - - let changed = GlyphChangedEntities { - point_ids: vec![parsed_id], - ..Default::default() + let change = { + let layer = self.editable_layer_mut(glyph_ref)?; + layer.toggle_smooth(parsed_id)?; + let changed = GlyphChangedEntities { + point_ids: vec![parsed_id], + ..Default::default() + }; + GlyphStructureChange::from_layer(layer, changed) }; - let change = GlyphStructureChange::from_layer(session.layer(), changed); - - self.mark_active_edit_changed(); + self.mark_font_changed(); Ok(change.into()) } @@ -1088,49 +941,55 @@ impl Bridge { #[napi] pub fn apply_position_patch( &mut self, + glyph_ref: GlyphLayerRef, point_ids: Option, point_coords: Option, anchor_ids: Option, anchor_coords: Option, ) -> errors::Result<()> { - let session = self.active_session_mut()?; - session.apply_bulk_node_positions(BulkNodePositionUpdates { - point_ids: point_ids.as_ref().map(|ids| { - let ids: &[u64] = ids; - ids - }), - point_coords: point_coords.as_ref().map(|coords| { - let coords: &[f64] = coords; - coords - }), - anchor_ids: anchor_ids.as_ref().map(|ids| { - let ids: &[u64] = ids; - ids - }), - anchor_coords: anchor_coords.as_ref().map(|coords| { - let coords: &[f64] = coords; - coords - }), - })?; + { + let layer = self.editable_layer_mut(glyph_ref)?; + layer.apply_bulk_node_positions(BulkNodePositionUpdates { + point_ids: point_ids.as_ref().map(|ids| { + let ids: &[u64] = ids; + ids + }), + point_coords: point_coords.as_ref().map(|coords| { + let coords: &[f64] = coords; + coords + }), + anchor_ids: anchor_ids.as_ref().map(|ids| { + let ids: &[u64] = ids; + ids + }), + anchor_coords: anchor_coords.as_ref().map(|coords| { + let coords: &[f64] = coords; + coords + }), + })?; + } - self.mark_active_edit_changed(); + self.mark_font_changed(); Ok(()) } #[napi] pub fn restore_state( &mut self, + glyph_ref: GlyphLayerRef, structure: NapiGlyphStructure, values: Float64Array, ) -> errors::Result { let structure = GlyphStructure::from(structure); let values: &[f64] = &values; - let session = self.active_session_mut()?; - session.restore_layer(&structure, values)?; + let change = { + let layer = self.editable_layer_mut(glyph_ref)?; + apply_state_to_layer(layer, &structure, values)?; + GlyphStructureChange::from_layer(layer, Default::default()) + }; - let change = GlyphStructureChange::from_layer(session.layer(), Default::default()); - self.mark_active_edit_changed(); + self.mark_font_changed(); Ok(change.into()) } } @@ -1138,7 +997,7 @@ impl Bridge { #[cfg(test)] mod tests { use super::*; - use shift_edit::{Contour, PointType}; + use shift_font::{Contour, PointType}; use std::time::{Duration, Instant}; fn glyph_handle(name: &str, unicode: Option) -> GlyphHandle { @@ -1212,6 +1071,13 @@ mod tests { bridge.get_sources()[0].id.clone() } + fn default_layer_ref(bridge: &Bridge, name: &str, unicode: Option) -> GlyphLayerRef { + GlyphLayerRef { + glyph_handle: glyph_handle(name, unicode), + layer_id: bridge.get_sources()[0].layer_id.clone(), + } + } + fn print_perf_mark(operation: &str, mark: PerfFontMark, elapsed: Duration) { eprintln!( "perf_mark {operation} [{}]: {} glyphs / {} points in {:?}", @@ -1229,7 +1095,6 @@ mod tests { let metadata = bridge.get_metadata(); let metrics = bridge.get_metrics(); - assert!(!bridge.has_edit_session()); assert_eq!(bridge.get_glyph_count(), 0); assert!(bridge.get_glyphs().is_empty()); assert_eq!(bridge.get_sources().len(), 1); @@ -1245,75 +1110,23 @@ mod tests { fn create_font_resets_to_fresh_font_state() { let mut bridge = Bridge::new(); bridge - .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) + .set_x_advance(default_layer_ref(&bridge, "A", Some(65)), 500.0) .unwrap(); bridge.create_font(); - assert!(!bridge.has_edit_session()); assert_eq!(bridge.get_glyph_count(), 0); assert!(bridge.get_axes().is_empty()); assert_eq!(bridge.get_sources().len(), 1); assert_eq!(bridge.get_sources()[0].name, "Regular"); } - #[test] - fn edit_session_tracks_current_glyph() { - let mut bridge = Bridge::new(); - - bridge - .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) - .unwrap(); - - assert!(bridge.has_edit_session()); - assert_eq!(bridge.get_editing_glyph_name().as_deref(), Some("A")); - assert_eq!( - bridge.get_editing_source_id().as_deref(), - Some(default_source_id(&bridge).as_str()) - ); - assert_eq!(bridge.get_editing_unicode(), Some(65)); - } - - #[test] - fn end_edit_session_commits_glyph_to_font() { - let mut bridge = Bridge::new(); - - bridge - .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) - .unwrap(); - bridge.end_edit_session().unwrap(); - - let glyphs = bridge.get_glyphs(); - assert!(!bridge.has_edit_session()); - assert_eq!(glyphs.len(), 1); - assert_eq!(glyphs[0].name, "A"); - assert_eq!(glyphs[0].unicodes, vec![65]); - } - - #[test] - fn starting_second_session_returns_bridge_error() { - let mut bridge = Bridge::new(); - - bridge - .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) - .unwrap(); - let result = bridge.start_edit_session(glyph_handle("B", Some(66)), default_source_id(&bridge)); - - assert_eq!( - result.unwrap_err().to_string(), - "edit session already active" - ); - assert_eq!(bridge.get_editing_glyph_name().as_deref(), Some("A")); - } - #[test] fn add_contour_returns_structure_change() { let mut bridge = Bridge::new(); - bridge - .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) - .unwrap(); + let glyph_ref = default_layer_ref(&bridge, "A", Some(65)); - let change = bridge.add_contour().unwrap(); + let change = bridge.add_contour(glyph_ref).unwrap(); assert_eq!(change.structure.contours.len(), 1); assert_eq!(change.changed.contour_ids.len(), 1); @@ -1325,14 +1138,24 @@ mod tests { } #[test] - fn save_snapshot_includes_active_edit_without_committing_session() { + fn save_snapshot_includes_direct_glyph_layer_edit() { let mut bridge = Bridge::new(); - bridge - .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) - .unwrap(); - let contour_id = bridge.add_contour().unwrap().changed.contour_ids[0].clone(); + let glyph_ref = default_layer_ref(&bridge, "A", Some(65)); + let contour_id = bridge + .add_contour(glyph_ref.clone()) + .unwrap() + .changed + .contour_ids[0] + .clone(); let point_id = bridge - .add_point(contour_id, 10.0, 20.0, NapiPointType::OnCurve, false) + .add_point( + glyph_ref, + contour_id, + 10.0, + 20.0, + NapiPointType::OnCurve, + false, + ) .unwrap() .changed .point_ids[0] @@ -1341,13 +1164,12 @@ mod tests { let snapshot = bridge.save_snapshot(); let glyph = snapshot .glyph("A") - .expect("snapshot should include active A"); + .expect("snapshot should include edited A"); let layer = glyph .layer(snapshot.default_layer_id()) - .expect("active glyph should include default layer"); + .expect("edited glyph should include default layer"); - assert!(bridge.has_edit_session()); - assert!(bridge.get_glyphs().is_empty()); + assert_eq!(bridge.get_glyphs().len(), 1); assert_eq!(glyph.unicodes(), &[65]); assert_eq!(layer.contours().len(), 1); assert_eq!( @@ -1361,10 +1183,8 @@ mod tests { #[test] fn save_task_routes_designspace_paths_through_font_loader() { let mut bridge = Bridge::new(); - bridge - .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) - .unwrap(); - bridge.add_contour().unwrap(); + let glyph_ref = default_layer_ref(&bridge, "A", Some(65)); + bridge.add_contour(glyph_ref).unwrap(); let dir = std::env::temp_dir().join("shift_bridge_designspace_save_task"); let designspace_path = dir.join("Smoke.designspace"); @@ -1403,14 +1223,24 @@ mod tests { #[test] fn persisted_older_snapshot_keeps_document_dirty_after_new_edit() { let mut bridge = Bridge::new(); - bridge - .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) - .unwrap(); - let contour_id = bridge.add_contour().unwrap().changed.contour_ids[0].clone(); + let glyph_ref = default_layer_ref(&bridge, "A", Some(65)); + let contour_id = bridge + .add_contour(glyph_ref.clone()) + .unwrap() + .changed + .contour_ids[0] + .clone(); let snapshot = bridge.save_snapshot(); bridge - .add_point(contour_id, 10.0, 20.0, NapiPointType::OnCurve, false) + .add_point( + glyph_ref, + contour_id, + 10.0, + 20.0, + NapiPointType::OnCurve, + false, + ) .unwrap(); record_persisted_version(&bridge.persisted_version, snapshot.version()); @@ -1423,14 +1253,11 @@ mod tests { #[test] fn load_resets_persisted_version_handle_for_old_async_saves() { let mut bridge = Bridge::new(); - bridge - .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) - .unwrap(); - bridge.add_contour().unwrap(); + let glyph_ref = default_layer_ref(&bridge, "A", Some(65)); + bridge.add_contour(glyph_ref).unwrap(); let old_persisted_version = bridge.persisted_version.clone(); bridge.font = Font::default(); - bridge.active_edit = None; bridge.live_version = DocumentVersion::default(); bridge.persisted_version = Arc::new(AtomicU64::new(0)); record_persisted_version(&old_persisted_version, DocumentVersion(1)); @@ -1439,31 +1266,26 @@ mod tests { assert!(!bridge.is_dirty()); } - #[test] - fn ending_dirty_edit_session_does_not_increment_version_again() { - let mut bridge = Bridge::new(); - bridge - .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) - .unwrap(); - bridge.add_contour().unwrap(); - - bridge.end_edit_session().unwrap(); - - assert_eq!(bridge.live_version().as_u32(), 1); - assert!(bridge.is_dirty()); - assert_eq!(bridge.get_glyphs()[0].name, "A"); - } - #[test] fn add_point_returns_structure_and_changed_point() { let mut bridge = Bridge::new(); - bridge - .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) - .unwrap(); - let contour_id = bridge.add_contour().unwrap().changed.contour_ids[0].clone(); + let glyph_ref = default_layer_ref(&bridge, "A", Some(65)); + let contour_id = bridge + .add_contour(glyph_ref.clone()) + .unwrap() + .changed + .contour_ids[0] + .clone(); let change = bridge - .add_point(contour_id, 10.0, 20.0, NapiPointType::OnCurve, false) + .add_point( + glyph_ref, + contour_id, + 10.0, + 20.0, + NapiPointType::OnCurve, + false, + ) .unwrap(); let points = &change.structure.contours[0].points; @@ -1475,22 +1297,32 @@ mod tests { } #[test] - fn get_glyph_state_reads_active_edit_overlay() { + fn get_glyph_state_reads_direct_glyph_layer_edit() { let mut bridge = Bridge::new(); + let glyph_ref = default_layer_ref(&bridge, "A", Some(65)); + let contour_id = bridge + .add_contour(glyph_ref.clone()) + .unwrap() + .changed + .contour_ids[0] + .clone(); bridge - .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) - .unwrap(); - let contour_id = bridge.add_contour().unwrap().changed.contour_ids[0].clone(); - bridge - .add_point(contour_id, 10.0, 20.0, NapiPointType::OnCurve, false) + .add_point( + glyph_ref, + contour_id, + 10.0, + 20.0, + NapiPointType::OnCurve, + false, + ) .unwrap(); let state = bridge .get_glyph_state(glyph_handle("A", Some(65)), default_source_id(&bridge)) .unwrap() - .expect("active edit glyph should be readable"); + .expect("edited glyph should be readable"); - assert!(bridge.get_glyphs().is_empty()); + assert_eq!(bridge.get_glyphs().len(), 1); assert_eq!(state.structure.contours.len(), 1); assert_eq!(state.structure.contours[0].points.len(), 1); assert_eq!(&state.values[..], &[500.0, 10.0, 20.0]); @@ -1507,77 +1339,42 @@ mod tests { } #[test] - fn edit_methods_require_active_session() { + fn edit_methods_require_valid_layer_ref() { let mut bridge = Bridge::new(); - let result = bridge.add_contour(); + let result = bridge.add_contour(GlyphLayerRef { + glyph_handle: glyph_handle("A", Some(65)), + layer_id: "not-a-layer-id".to_string(), + }); - assert_eq!(result.err().unwrap().reason, "no active edit"); + assert!(result.err().unwrap().reason.contains("invalid layer ID")); } #[test] - fn perf_mark_save_snapshot_setup_with_active_edit_overlay() { + fn perf_mark_save_snapshot_setup_with_committed_font() { let committed_mark = PerfFontMark { label: "cjk-scale committed", glyphs: 10_000, contours_per_glyph: 2, points_per_contour: 8, }; - let active_mark = PerfFontMark { - label: "active-overlay", - glyphs: 1, - contours_per_glyph: 50, - points_per_contour: 1_000, - }; let mut bridge = Bridge::new(); bridge.font = point_heavy_font(committed_mark); - let default_layer_id = bridge.font.default_layer_id(); - let active_glyph = point_heavy_glyph("active", 0xE000, default_layer_id, active_mark); - bridge.active_edit = Some(ActiveEdit::from_glyph( - active_glyph, - bridge - .font - .default_source_id() - .expect("test font should have a default source"), - default_layer_id, - Some(0xE000), - )); - bridge.mark_active_edit_changed(); let start = Instant::now(); let snapshots: Vec<_> = (0..128).map(|_| bridge.save_snapshot()).collect(); let elapsed = start.elapsed(); for snapshot in &snapshots { - let active_glyph = snapshot - .glyph("active") - .expect("snapshot should include the active edit overlay"); - let active_layer = active_glyph - .layer(snapshot.default_layer_id()) - .expect("active overlay should include the default layer"); - assert_eq!(snapshot.version().as_u32(), bridge.live_version().as_u32()); - assert_eq!(snapshot.glyphs().len(), committed_mark.glyphs + 1); - assert_eq!(active_glyph.unicodes(), &[0xE000]); - assert_eq!( - active_layer.contours().len(), - active_mark.contours_per_glyph - ); + assert_eq!(snapshot.glyphs().len(), committed_mark.glyphs); } - assert!(bridge.has_edit_session()); assert_eq!(bridge.get_glyph_count(), committed_mark.glyphs as u32); - print_perf_mark( - "save_snapshot active overlay x128", - PerfFontMark { - label: "cjk-scale + active-overlay", - ..committed_mark - }, - elapsed, - ); + print_perf_mark("save_snapshot committed x128", committed_mark, elapsed); assert!( elapsed < Duration::from_secs(1), - "active-overlay save snapshot setup should stay comfortably sub-second; got {elapsed:?}" + "committed save snapshot setup should stay comfortably sub-second; got {elapsed:?}" ); } } diff --git a/crates/shift-bridge/src/errors.rs b/crates/shift-bridge/src/errors.rs index a55fd43e..cf7833d8 100644 --- a/crates/shift-bridge/src/errors.rs +++ b/crates/shift-bridge/src/errors.rs @@ -1,15 +1,9 @@ use napi::{Error, JsError, Status}; use shift_backends::BackendError; -use shift_edit::error::CoreError; +use shift_font::error::CoreError; #[derive(Debug, thiserror::Error)] pub enum BridgeError { - #[error("no active edit")] - NoActiveEdit, - - #[error("edit session already active")] - ActiveEditAlreadyExists, - #[error("invalid {kind}: {value}")] InvalidInput { kind: &'static str, value: String }, @@ -28,10 +22,7 @@ pub fn to_napi_error(error: BridgeError) -> Error { | BridgeError::Backend(BackendError::InvalidExtensionUtf8 { .. }) | BridgeError::Backend(BackendError::UnsupportedFormat { .. }) | BridgeError::Backend(BackendError::UnsupportedWriteFormat { .. }) => Status::InvalidArg, - BridgeError::NoActiveEdit - | BridgeError::ActiveEditAlreadyExists - | BridgeError::Core(_) - | BridgeError::Backend(_) => Status::GenericFailure, + BridgeError::Core(_) | BridgeError::Backend(_) => Status::GenericFailure, }; Error::new(status, error.to_string()) diff --git a/crates/shift-bridge/src/input.rs b/crates/shift-bridge/src/input.rs index f25ebc4f..2f622efa 100644 --- a/crates/shift-bridge/src/input.rs +++ b/crates/shift-bridge/src/input.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -use shift_ir::{AnchorId, ComponentId, ContourId, GuidelineId, LayerId, PointId, SourceId}; +use shift_font::{AnchorId, ComponentId, ContourId, GuidelineId, LayerId, PointId, SourceId}; use crate::errors::{BridgeError, BridgeResult}; diff --git a/crates/shift-document/Cargo.toml b/crates/shift-document/Cargo.toml index 8da5049e..87535a8a 100644 --- a/crates/shift-document/Cargo.toml +++ b/crates/shift-document/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -shift-ir = { workspace = true } +shift-font = { workspace = true } shift-store = { workspace = true } thiserror = "2" diff --git a/crates/shift-document/README.md b/crates/shift-document/README.md index bae74cc4..15e38a9e 100644 --- a/crates/shift-document/README.md +++ b/crates/shift-document/README.md @@ -5,26 +5,25 @@ A document coordinates the lower-level crates: - `shift-store` for the SQLite working store; -- `shift-ir` for the live in-memory projection; -- `shift-edit` for interactive editing operations. +- `shift-font` for the live authoring model and interactive editing operations. -Consumers interact with document operations, not raw SQL, raw `Font` mutation, or edit-session internals. +Consumers interact with document operations, not low-level SQL, raw `Font` mutation, or edit-session internals. ## Responsibilities - create and open durable Shift documents; - coordinate writes to the SQLite working store; -- maintain the live `shift-ir` projection used by editing, rendering, and export; +- maintain the live `shift-font` projection used by editing, rendering, and export; - define where interactive edits become durable document state; - provide Rust-native operations that bridge, CLI, and future app surfaces can wrap. ## Boundaries -`shift-document` should not contain raw SQL. SQL belongs in `shift-store`. +`shift-document` should not contain low-level SQL. SQL belongs in `shift-store`. `shift-document` should not contain TypeScript or NAPI types. Those belong in `shift-bridge` and `shift-wire`. -`shift-document` should not implement low-level vector editing algorithms. Those belong in `shift-edit`. +`shift-document` should not implement low-level vector editing algorithms. Those belong in `shift-font`. `shift-document` should not treat SQLite as a pluggable backend. SQLite is the working store for editable Shift documents. diff --git a/crates/shift-document/src/document.rs b/crates/shift-document/src/document.rs index 9d0eafe3..94b8c9e6 100644 --- a/crates/shift-document/src/document.rs +++ b/crates/shift-document/src/document.rs @@ -3,7 +3,7 @@ use std::path::Path; use crate::{DocumentError, NewDocument}; pub struct ShiftDocument { - font: shift_ir::Font, + font: shift_font::Font, store: shift_store::ShiftStore, } @@ -16,16 +16,16 @@ impl ShiftDocument { store.set_font_info(new_document.font_info())?; Ok(Self { - font: shift_ir::Font::new(), + font: shift_font::Font::new(), store, }) } - pub fn from_parts(font: shift_ir::Font, store: shift_store::ShiftStore) -> Self { + pub fn from_parts(font: shift_font::Font, store: shift_store::ShiftStore) -> Self { Self { font, store } } - pub fn font(&self) -> &shift_ir::Font { + pub fn font(&self) -> &shift_font::Font { &self.font } diff --git a/crates/shift-document/src/new_document.rs b/crates/shift-document/src/new_document.rs index 66bb6664..3d6159d4 100644 --- a/crates/shift-document/src/new_document.rs +++ b/crates/shift-document/src/new_document.rs @@ -9,7 +9,7 @@ impl Default for NewDocument { fn default() -> Self { Self { family_name: DEFAULT_FAMILY_NAME.to_string(), - units_per_em: shift_ir::FontMetrics::default().units_per_em as i64, + units_per_em: shift_font::FontMetrics::default().units_per_em as i64, } } } diff --git a/crates/shift-edit/Cargo.toml b/crates/shift-edit/Cargo.toml deleted file mode 100644 index e4c470b3..00000000 --- a/crates/shift-edit/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -edition = "2021" -name = "shift-edit" -version = "0.0.0" -authors = ["Kostya Farber "] -license = "MIT" - -[lib] -crate-type = ["rlib"] - -[dependencies] -shift-ir = { workspace = true } -shift-backends = { workspace = true } -shift-wire = { workspace = true } - -bitflags = "2.9.1" -fontc = "0.2.0" -fontdrasil = "0.4.0" -norad = "0.16.0" -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0" -skrifa = "0.32.0" -thiserror = "2.0.18" diff --git a/crates/shift-edit/docs/DOCS.md b/crates/shift-edit/docs/DOCS.md deleted file mode 100644 index e2f896df..00000000 --- a/crates/shift-edit/docs/DOCS.md +++ /dev/null @@ -1,82 +0,0 @@ -# shift-edit - -Editing logic and composite helpers for the Shift font editor. - -## Architecture Invariants - -**Architecture Invariant:** `EditSession` operates on a `GlyphLayer`, not a full `Glyph`. A session holds one editable layer plus glyph metadata. WHY: layers are the unit of editing in font design. - -**Architecture Invariant:** Core data types (`Font`, `Glyph`, `GlyphLayer`, `Contour`, `Point`, entity IDs) live in `shift-ir`, not here. `shift-edit` re-exports them from `shift_ir` for convenience. - -**Architecture Invariant:** State restore uses `GlyphStructure + values`, not old bridge snapshots. The structure owns stable entity ordering; values are the flat numeric payload in that order. - -**Architecture Invariant:** `shift-edit` must not export TypeScript types. Bridge DTOs live in `shift-wire`; TypeScript declarations are generated from `shift-bridge/index.d.ts` into `@shift/types/bridge`. - -**Architecture Invariant:** Composite resolution is read-only. `flatten_component_contours_for_layer` and `resolve_component_instances_for_layer` produce derived geometry and never mutate source glyphs. - -## Codemap - -``` -src/ - lib.rs -- public API and shift-ir re-exports - edit_session.rs -- mutable glyph-layer editing context - state.rs -- GlyphStructure/values restore helpers and flat value extraction - composite.rs -- composite glyph resolution and derived contours - dependency_graph.rs -- component dependency index - curve.rs -- tight curve bounds helpers - vec2.rs -- 2D vector math -``` - -## Key Types - -- `EditSession` -- mutable editing context wrapping a `GlyphLayer` with glyph metadata. -- `BulkNodePositionUpdates` -- typed-array-friendly absolute position update payload for hot path sync. -- `EditableNode` -- point/anchor reference enum for editable node operations. -- `GlyphStructure` / values -- state restore and bridge-facing edit result shape, owned canonically by `shift-wire`. -- `DependencyGraph` -- bidirectional component dependency index. -- `ResolvedContour` -- derived contour from composite flattening. - -## Editing Flow - -1. Caller creates an `EditSession` from a glyph name, unicode, and `GlyphLayer`. -2. The session mutates contours, points, anchors, width, and bulk node positions. -3. Bridge methods convert the session layer into `shift-wire` structure/value change DTOs. -4. Undo/redo restore rebuilds layer content from `GlyphStructure + values`. -5. The session is consumed via `into_layer()` when the bridge commits the active edit back to the font. - -## Workflow Recipes - -### Add a new editing operation to EditSession - -1. Add the method to `EditSession` in `edit_session.rs`. -2. If it returns results to JS, create or reuse a DTO in `shift-wire`. -3. Wire the method through `shift-bridge` NAPI bindings. -4. Rebuild `shift-bridge` declarations. -5. Run `pnpm generate:bridge-types`. -6. Run `cargo test -p shift-edit` and `cargo test -p shift-bridge`. - -### Add a new bridge field - -1. Add the canonical DTO field in `shift-wire`. -2. Add the NAPI adapter field in `shift-wire/src/bridges/napi`. -3. Rebuild `shift-bridge` declarations. -4. Run `pnpm generate:bridge-types` to update `@shift/types/bridge`. - -## Gotchas - -- `apply_boolean_op` removes both input contours even if the boolean operation produces zero output contours. -- Point lookup across contours is linear. For hot paths with many points, prefer bulk position APIs that iterate contours once. -- Composite-derived points are render-time artifacts, not editable identities. - -## Verification - -```bash -cargo test -p shift-edit -cargo clippy -p shift-edit -``` - -## Related - -- `shift-ir` -- canonical Rust data model. -- `shift-wire` -- bridge DTOs and NAPI adapter wrappers. -- `shift-bridge` -- NAPI bridge exposing edit/session/persistence operations. diff --git a/crates/shift-edit/src/dependency_graph.rs b/crates/shift-edit/src/dependency_graph.rs deleted file mode 100644 index 3592e38a..00000000 --- a/crates/shift-edit/src/dependency_graph.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Glyph dependency graph for component relationships. -//! -//! Directionality: -//! - `uses`: `A -> B` means glyph `A` uses `B` as a component. -//! - `used_by`: inverse index used to query "what depends on this glyph?". -//! -//! The graph is currently rebuilt from the full font model when needed. - -use crate::Font; -use std::collections::{HashMap, HashSet}; - -/// Component dependency index across glyphs. -/// -/// This graph stores both forward (`uses`) and reverse (`used_by`) edges so -/// callers can answer dependent queries efficiently. -#[derive(Default, Clone, Debug)] -pub struct DependencyGraph { - uses: HashMap>, - used_by: HashMap>, -} - -impl DependencyGraph { - /// Rebuilds the dependency graph from all glyph layers in `font`. - pub fn rebuild(font: &Font) -> Self { - let mut graph = Self::default(); - for (composite_name, glyph) in font.glyphs() { - for layer in glyph.layers().values() { - for component in layer.components_iter() { - graph.add_edge(composite_name, component.base_glyph()); - } - } - } - graph - } - - /// Adds a directed edge `composite -> component`. - pub fn add_edge(&mut self, composite: &str, component: &str) { - self.uses - .entry(composite.to_string()) - .or_default() - .insert(component.to_string()); - self.used_by - .entry(component.to_string()) - .or_default() - .insert(composite.to_string()); - } - - /// Returns all glyph names that (transitively) depend on `glyph_name`. - /// - /// The root `glyph_name` is excluded from the output, even if cycles are - /// present. - pub fn dependents_recursive(&self, glyph_name: &str) -> HashSet { - let mut result = HashSet::new(); - let mut stack = vec![glyph_name.to_string()]; - - while let Some(current) = stack.pop() { - let Some(dependents) = self.used_by.get(¤t) else { - continue; - }; - - for dependent in dependents { - if dependent == glyph_name { - continue; - } - if result.insert(dependent.clone()) { - stack.push(dependent.clone()); - } - } - } - - result - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Font, Glyph, GlyphLayer}; - use shift_ir::Component; - - #[test] - fn recursive_dependents_includes_transitive_glyphs() { - let mut font = Font::new(); - let layer_id = font.default_layer_id(); - - let mut a = Glyph::new("A".to_string()); - a.set_layer(layer_id, GlyphLayer::with_width(600.0)); - - let mut aacute = Glyph::new("Aacute".to_string()); - let mut aacute_layer = GlyphLayer::with_width(600.0); - aacute_layer.add_component(Component::new("A".to_string())); - aacute.set_layer(layer_id, aacute_layer); - - let mut aacute_alt = Glyph::new("Aacute.alt".to_string()); - let mut aacute_alt_layer = GlyphLayer::with_width(600.0); - aacute_alt_layer.add_component(Component::new("Aacute".to_string())); - aacute_alt.set_layer(layer_id, aacute_alt_layer); - - font.insert_glyph(a); - font.insert_glyph(aacute); - font.insert_glyph(aacute_alt); - - let graph = DependencyGraph::rebuild(&font); - let dependents = graph.dependents_recursive("A"); - - assert!(dependents.contains("Aacute")); - assert!(dependents.contains("Aacute.alt")); - } -} diff --git a/crates/shift-edit/src/lib.rs b/crates/shift-edit/src/lib.rs deleted file mode 100644 index 64b8136e..00000000 --- a/crates/shift-edit/src/lib.rs +++ /dev/null @@ -1,27 +0,0 @@ -pub mod composite; -pub mod curve; -pub mod dependency_graph; -pub mod edit_session; -pub mod error; -pub mod interpolation; -pub mod state; -pub mod vec2; - -pub use shift_wire::{ - values_from_layer, AnchorData, ComponentData, ContourData, GlyphMaster, GlyphState, - GlyphStructure, GlyphStructureChange, GlyphValueChange, GlyphVariationData, PointData, -}; - -pub use shift_ir::{ - Anchor, AnchorId, Axis, BooleanOp, Contour, ContourId, CurveSegment, CurveSegmentIter, Font, - FontMetadata, FontMetrics, Glyph, GlyphLayer, GlyphName, GuidelineId, LayerId, Location, Point, - PointId, PointType, Source, SourceId, Transform, -}; - -pub use shift_backends::font_loader; -pub use shift_backends::ufo::{UfoReader, UfoWriter}; -pub use shift_backends::{FontBackend, FontReader, FontWriter}; - -pub use edit_session::{ - BulkNodePositionUpdates, EditableNode, PasteContour, PastePoint, PasteResult, -}; diff --git a/crates/shift-edit/src/vec2.rs b/crates/shift-edit/src/vec2.rs deleted file mode 100644 index 7e0f0ee5..00000000 --- a/crates/shift-edit/src/vec2.rs +++ /dev/null @@ -1,176 +0,0 @@ -use std::ops::{Add, Mul, Neg, Sub}; - -use crate::Point; - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Vec2 { - pub x: f64, - pub y: f64, -} - -impl Vec2 { - pub const ZERO: Vec2 = Vec2 { x: 0.0, y: 0.0 }; - - pub fn new(x: f64, y: f64) -> Self { - Self { x, y } - } - - pub fn length(&self) -> f64 { - (self.x * self.x + self.y * self.y).sqrt() - } - - pub fn length_squared(&self) -> f64 { - self.x * self.x + self.y * self.y - } - - pub fn normalize(&self) -> Self { - let len = self.length(); - if len < 1e-10 { - *self - } else { - Self { - x: self.x / len, - y: self.y / len, - } - } - } - - pub fn dot(&self, other: Self) -> f64 { - self.x * other.x + self.y * other.y - } - - pub fn distance(&self, other: Self) -> f64 { - (*self - other).length() - } -} - -impl From<(f64, f64)> for Vec2 { - fn from((x, y): (f64, f64)) -> Self { - Self { x, y } - } -} - -impl From for (f64, f64) { - fn from(v: Vec2) -> Self { - (v.x, v.y) - } -} - -impl From<&Point> for Vec2 { - fn from(p: &Point) -> Self { - Self { x: p.x(), y: p.y() } - } -} - -impl Add for Vec2 { - type Output = Self; - fn add(self, rhs: Self) -> Self { - Self { - x: self.x + rhs.x, - y: self.y + rhs.y, - } - } -} - -impl Sub for Vec2 { - type Output = Self; - fn sub(self, rhs: Self) -> Self { - Self { - x: self.x - rhs.x, - y: self.y - rhs.y, - } - } -} - -impl Mul for Vec2 { - type Output = Self; - fn mul(self, scalar: f64) -> Self { - Self { - x: self.x * scalar, - y: self.y * scalar, - } - } -} - -impl Neg for Vec2 { - type Output = Self; - fn neg(self) -> Self { - Self { - x: -self.x, - y: -self.y, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_length() { - let v = Vec2::new(3.0, 4.0); - assert!((v.length() - 5.0).abs() < 1e-10); - } - - #[test] - fn test_normalize() { - let v = Vec2::new(3.0, 4.0); - let n = v.normalize(); - assert!((n.length() - 1.0).abs() < 1e-10); - assert!((n.x - 0.6).abs() < 1e-10); - assert!((n.y - 0.8).abs() < 1e-10); - } - - #[test] - fn test_normalize_zero_vector() { - let v = Vec2::ZERO; - let n = v.normalize(); - assert_eq!(n, Vec2::ZERO); - } - - #[test] - fn test_dot() { - let a = Vec2::new(1.0, 0.0); - let b = Vec2::new(0.0, 1.0); - assert!((a.dot(b)).abs() < 1e-10); - - let c = Vec2::new(1.0, 0.0); - let d = Vec2::new(-1.0, 0.0); - assert!((c.dot(d) - (-1.0)).abs() < 1e-10); - } - - #[test] - fn test_distance() { - let a = Vec2::new(0.0, 0.0); - let b = Vec2::new(3.0, 4.0); - assert!((a.distance(b) - 5.0).abs() < 1e-10); - } - - #[test] - fn test_operators() { - let a = Vec2::new(1.0, 2.0); - let b = Vec2::new(3.0, 4.0); - - let sum = a + b; - assert_eq!(sum, Vec2::new(4.0, 6.0)); - - let diff = b - a; - assert_eq!(diff, Vec2::new(2.0, 2.0)); - - let scaled = a * 2.0; - assert_eq!(scaled, Vec2::new(2.0, 4.0)); - - let neg = -a; - assert_eq!(neg, Vec2::new(-1.0, -2.0)); - } - - #[test] - fn test_tuple_conversion() { - let tuple = (3.0, 4.0); - let v: Vec2 = tuple.into(); - assert_eq!(v, Vec2::new(3.0, 4.0)); - - let back: (f64, f64) = v.into(); - assert_eq!(back, tuple); - } -} diff --git a/crates/shift-font/Cargo.toml b/crates/shift-font/Cargo.toml index b3c6979b..c73384bf 100644 --- a/crates/shift-font/Cargo.toml +++ b/crates/shift-font/Cargo.toml @@ -14,3 +14,4 @@ indexmap = { version = "2", features = ["serde"] } kurbo = "0.13.0" linesweeper = "0.3.0" serde = { version = "1.0", features = ["derive", "rc"] } +thiserror = "2.0.18" diff --git a/crates/shift-font/docs/DOCS.md b/crates/shift-font/docs/DOCS.md new file mode 100644 index 00000000..34feed90 --- /dev/null +++ b/crates/shift-font/docs/DOCS.md @@ -0,0 +1,31 @@ +# shift-font + +First-class Rust font object model for Shift. + +## Responsibilities + +- 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, composite resolution, interpolation support, and geometry-derived behavior. +- Stay independent of TypeScript, NAPI, and bridge DTOs. + +## Boundaries + +`shift-font` should not expose TypeScript-facing wire contracts. Those belong in `shift-wire`. + +`shift-font` should not perform SQLite persistence. Durable working-store reads and writes belong in `shift-store`. + +`shift-font` should not own Electron, renderer, or tool state. The TypeScript editor owns UI interaction, selection, hover, camera, tools, and command history. + +## Editing Shape + +Mutations should live on the model object being mutated: + +```rust +layer.add_empty_contour(); +layer.add_point_to_contour(contour_id, x, y, point_type, smooth)?; +layer.remove_points(&point_ids)?; +layer.apply_bulk_node_positions(updates)?; +``` + +Transport layers should pass enough identity to find the model object, then call these methods. They should not introduce hidden native edit sessions. diff --git a/crates/shift-edit/src/composite.rs b/crates/shift-font/src/composite.rs similarity index 99% rename from crates/shift-edit/src/composite.rs rename to crates/shift-font/src/composite.rs index 6dadee84..26cc8980 100644 --- a/crates/shift-edit/src/composite.rs +++ b/crates/shift-font/src/composite.rs @@ -471,8 +471,7 @@ fn compose_transform(outer: Transform, inner: Transform) -> Transform { #[cfg(test)] mod tests { use super::*; - use crate::{Anchor, Contour, Font, Glyph, GlyphLayer, PointType, Transform}; - use shift_ir::Component; + use crate::{Anchor, Component, Contour, Font, Glyph, GlyphLayer, PointType, Transform}; fn two_point_contour(x0: f64, y0: f64, x1: f64, y1: f64) -> Contour { let mut contour = Contour::new(); diff --git a/crates/shift-edit/src/curve.rs b/crates/shift-font/src/curve.rs similarity index 100% rename from crates/shift-edit/src/curve.rs rename to crates/shift-font/src/curve.rs diff --git a/crates/shift-edit/src/error.rs b/crates/shift-font/src/error.rs similarity index 95% rename from crates/shift-edit/src/error.rs rename to crates/shift-font/src/error.rs index e9d1676a..56a4292f 100644 --- a/crates/shift-edit/src/error.rs +++ b/crates/shift-font/src/error.rs @@ -1,4 +1,4 @@ -use shift_ir::{AnchorId, ContourId, PointId}; +use crate::{AnchorId, ContourId, PointId}; #[derive(Debug, thiserror::Error)] pub enum CoreError { diff --git a/crates/shift-edit/src/edit_session.rs b/crates/shift-font/src/layer_edit.rs similarity index 87% rename from crates/shift-edit/src/edit_session.rs rename to crates/shift-font/src/layer_edit.rs index ba42eb07..f0238030 100644 --- a/crates/shift-edit/src/edit_session.rs +++ b/crates/shift-font/src/layer_edit.rs @@ -1,12 +1,30 @@ use crate::{ + boolean, error::{CoreError, CoreResult}, - state::apply_state_to_layer, - GlyphStructure, PointId, PointType, Transform, + Anchor, AnchorId, BooleanOp, ComponentId, Contour, ContourId, GlyphLayer, Point, PointId, + PointType, Transform, }; -use shift_ir::{boolean, Anchor, AnchorId, BooleanOp, Contour, ContourId, GlyphLayer, Point}; -use shift_wire::{GlyphChangedEntities, GlyphValue}; use std::collections::{HashMap, HashSet}; +pub type GlyphValue = f64; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ChangedEntities { + pub point_ids: Vec, + pub anchor_ids: Vec, + pub contour_ids: Vec, + pub component_ids: Vec, +} + +impl ChangedEntities { + pub fn points(point_ids: Vec) -> Self { + Self { + point_ids, + ..Default::default() + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum EditableNode { Point(PointId), @@ -168,52 +186,9 @@ fn invalid_position_update_input(kind: &'static str, message: impl Into) } } -pub struct EditSession { - layer: GlyphLayer, - glyph_name: String, - unicode: u32, -} - -impl EditSession { - pub fn new(name: String, unicode: u32, layer: GlyphLayer) -> Self { - Self { - layer, - glyph_name: name, - unicode, - } - } - - pub fn layer(&self) -> &GlyphLayer { - &self.layer - } - - pub fn layer_mut(&mut self) -> &mut GlyphLayer { - &mut self.layer - } - - pub fn into_layer(self) -> GlyphLayer { - self.layer - } - - pub fn glyph_name(&self) -> &str { - &self.glyph_name - } - - pub fn unicode(&self) -> u32 { - self.unicode - } - - pub fn width(&self) -> f64 { - self.layer.width() - } -} - -impl EditSession { +impl GlyphLayer { fn contour_mut_or_err(&mut self, id: ContourId) -> CoreResult<&mut Contour> { - let contour = self - .layer - .contour_mut(id) - .ok_or(CoreError::ContourNotFound(id))?; + let contour = self.contour_mut(id).ok_or(CoreError::ContourNotFound(id))?; Ok(contour) } @@ -240,8 +215,7 @@ impl EditSession { } fn anchor_mut_or_err(&mut self, anchor_id: AnchorId) -> CoreResult<&mut Anchor> { - self.layer - .anchor_mut(anchor_id) + self.anchor_mut(anchor_id) .ok_or(CoreError::AnchorNotFound(anchor_id)) } @@ -277,7 +251,7 @@ impl EditSession { fn anchors_exist_or_err(&self, anchor_ids: &[AnchorId]) -> CoreResult<()> { for anchor_id in anchor_ids { - if self.layer.anchor(*anchor_id).is_none() { + if self.anchor(*anchor_id).is_none() { return Err(CoreError::AnchorNotFound(*anchor_id)); } } @@ -289,7 +263,7 @@ impl EditSession { updates: &HashMap, ) -> CoreResult<()> { for anchor_id in updates.keys() { - if self.layer.anchor(*anchor_id).is_none() { + if self.anchor(*anchor_id).is_none() { return Err(CoreError::AnchorNotFound(*anchor_id)); } } @@ -308,7 +282,7 @@ impl EditSession { return Ok(()); } - for contour in self.layer.contours_iter_mut() { + for contour in self.contours_iter_mut() { for point in contour.points_mut() { if remaining.remove(&point.id()) { update(point); @@ -332,7 +306,7 @@ impl EditSession { return Ok(()); } - for contour in self.layer.contours_iter_mut() { + for contour in self.contours_iter_mut() { for point in contour.points_mut() { let point_id = point.id(); if let Some(position) = updates.get(&point_id) { @@ -394,9 +368,9 @@ impl EditSession { } } -impl EditSession { +impl GlyphLayer { pub fn set_x_advance(&mut self, width: f64) { - self.layer.set_width(width); + self.set_width(width); } /// Translate all editable glyph geometry in the active layer. @@ -404,51 +378,35 @@ impl EditSession { /// This moves contour points, anchors, and component transforms. /// Glyph advance width is intentionally left unchanged. pub fn translate_layer(&mut self, dx: f64, dy: f64) { - for contour in self.layer.contours_iter_mut() { + for contour in self.contours_iter_mut() { for point in contour.points_mut() { point.translate(dx, dy); } } - let anchor_ids: Vec<_> = self - .layer - .anchors_iter() - .map(|anchor| anchor.id()) - .collect(); - self.layer.move_anchors(&anchor_ids, dx, dy); + let anchor_ids: Vec<_> = self.anchors_iter().map(|anchor| anchor.id()).collect(); + self.move_anchors(&anchor_ids, dx, dy); - let component_ids: Vec<_> = self.layer.components().keys().cloned().collect(); + let component_ids: Vec<_> = self.components().keys().cloned().collect(); for component_id in component_ids { - if let Some(mut component) = self.layer.remove_component(component_id) { + if let Some(mut component) = self.remove_component(component_id) { component.translate(dx, dy); - self.layer.add_component(component); + self.add_component(component); } } } - - pub fn restore_layer( - &mut self, - structure: &GlyphStructure, - values: &[GlyphValue], - ) -> CoreResult<()> { - let mut new_layer = self.layer.clone(); - apply_state_to_layer(&mut new_layer, structure, values)?; - self.layer = new_layer; - Ok(()) - } } -impl EditSession { +impl GlyphLayer { pub fn add_empty_contour(&mut self) -> ContourId { let contour = Contour::new(); let contour_id = contour.id(); - self.layer.add_contour(contour); + self.add_contour(contour); contour_id } - pub fn remove_contour(&mut self, contour_id: ContourId) -> CoreResult { - self.layer - .remove_contour(contour_id) + pub fn remove_contour_checked(&mut self, contour_id: ContourId) -> CoreResult { + self.remove_contour(contour_id) .ok_or(CoreError::ContourNotFound(contour_id)) } @@ -477,12 +435,10 @@ impl EditSession { op: BooleanOp, ) -> CoreResult> { let a = self - .layer .contour(contour_id_a) .ok_or(CoreError::ContourNotFound(contour_id_a))? .clone(); let b = self - .layer .contour(contour_id_b) .ok_or(CoreError::ContourNotFound(contour_id_b))? .clone(); @@ -490,12 +446,12 @@ impl EditSession { let result = boolean(op, &a, &b).map_err(|e| CoreError::BooleanOperationFailed(e.to_string()))?; - self.remove_contour(contour_id_a)?; - self.remove_contour(contour_id_b)?; + self.remove_contour_checked(contour_id_a)?; + self.remove_contour_checked(contour_id_b)?; let mut created_ids = Vec::new(); for contour in result.0 { - let id = self.layer.add_contour(contour); + let id = self.add_contour(contour); created_ids.push(id); } @@ -503,7 +459,7 @@ impl EditSession { } pub fn find_point_contour(&self, point_id: PointId) -> Option { - for contour in self.layer.contours_iter() { + for contour in self.contours_iter() { if contour.get_point(point_id).is_some() { return Some(contour.id()); } @@ -512,7 +468,7 @@ impl EditSession { } } -impl EditSession { +impl GlyphLayer { pub fn add_point_to_contour( &mut self, contour_id: ContourId, @@ -607,14 +563,24 @@ impl EditSession { } } -impl EditSession { +impl GlyphLayer { /// Set absolute position for a single anchor - pub fn set_anchor_position(&mut self, anchor_id: AnchorId, x: f64, y: f64) -> CoreResult<()> { + pub fn set_anchor_position_checked( + &mut self, + anchor_id: AnchorId, + x: f64, + y: f64, + ) -> CoreResult<()> { self.anchor_mut_or_err(anchor_id)?.set_position(x, y); Ok(()) } - pub fn move_anchors(&mut self, anchor_ids: &[AnchorId], dx: f64, dy: f64) -> CoreResult<()> { + pub fn move_anchors_checked( + &mut self, + anchor_ids: &[AnchorId], + dx: f64, + dy: f64, + ) -> CoreResult<()> { self.anchors_exist_or_err(anchor_ids)?; for anchor_id in anchor_ids { @@ -644,7 +610,7 @@ impl EditSession { self.anchors_exist_or_err(&groups.anchors)?; self.move_points(&groups.points, dx, dy)?; - self.move_anchors(&groups.anchors, dx, dy) + self.move_anchors_checked(&groups.anchors, dx, dy) } pub fn transform_nodes( @@ -663,9 +629,9 @@ impl EditSession { pub fn set_bulk_node_positions( &mut self, updates: BulkNodePositionUpdates<'_>, - ) -> CoreResult { + ) -> CoreResult { let groups = Self::bulk_node_position_updates(updates)?; - let changed = GlyphChangedEntities { + let changed = ChangedEntities { point_ids: groups.points.keys().copied().collect(), anchor_ids: groups.anchors.keys().copied().collect(), ..Default::default() @@ -684,25 +650,13 @@ impl EditSession { } } -impl EditSession { - pub fn contour(&self, id: ContourId) -> Option<&Contour> { - self.layer.contour(id) - } - - pub fn contour_mut(&mut self, id: ContourId) -> Option<&mut Contour> { - self.layer.contour_mut(id) - } - - pub fn contours_iter(&self) -> impl Iterator { - self.layer.contours_iter() - } - +impl GlyphLayer { pub fn contours_count(&self) -> usize { - self.layer.contours().len() + self.contours().len() } } -impl EditSession { +impl GlyphLayer { pub fn paste_contours( &mut self, contours: Vec, @@ -729,7 +683,7 @@ impl EditSession { contour.close(); } - let contour_id = self.layer.add_contour(contour); + let contour_id = self.add_contour(contour); created_contour_ids.push(contour_id); } @@ -766,32 +720,30 @@ pub struct PasteResult { #[cfg(test)] mod tests { use super::*; - use shift_ir::{Anchor, Component}; + use crate::{Anchor, Component}; - fn create_session() -> EditSession { - EditSession::new("test".to_string(), 65, GlyphLayer::with_width(500.0)) + fn create_session() -> GlyphLayer { + GlyphLayer::with_width(500.0) } - fn session_with_contour() -> (EditSession, ContourId) { + fn session_with_contour() -> (GlyphLayer, ContourId) { let mut session = create_session(); let contour_id = session.add_empty_contour(); (session, contour_id) } - fn add_point(session: &mut EditSession, contour_id: ContourId, x: f64, y: f64) -> PointId { + fn add_point(session: &mut GlyphLayer, contour_id: ContourId, x: f64, y: f64) -> PointId { session .add_point_to_contour(contour_id, x, y, PointType::OnCurve, false) .unwrap() } - fn add_anchor(session: &mut EditSession, x: f64, y: f64) -> AnchorId { - session - .layer_mut() - .add_anchor(Anchor::new(Some("top".to_string()), x, y)) + fn add_anchor(session: &mut GlyphLayer, x: f64, y: f64) -> AnchorId { + session.add_anchor(Anchor::new(Some("top".to_string()), x, y)) } fn point_position( - session: &EditSession, + session: &GlyphLayer, contour_id: ContourId, point_id: PointId, ) -> (f64, f64) { @@ -803,8 +755,8 @@ mod tests { (point.x(), point.y()) } - fn anchor_position(session: &EditSession, anchor_id: AnchorId) -> (f64, f64) { - let anchor = session.layer().anchor(anchor_id).unwrap(); + fn anchor_position(session: &GlyphLayer, anchor_id: AnchorId) -> (f64, f64) { + let anchor = session.anchor(anchor_id).unwrap(); (anchor.x(), anchor.y()) } @@ -812,7 +764,7 @@ mod tests { fn remove_contour_removes_contour() { let (mut session, contour_id) = session_with_contour(); - session.remove_contour(contour_id).unwrap(); + session.remove_contour_checked(contour_id).unwrap(); assert_eq!(session.contours_count(), 0); } @@ -823,7 +775,7 @@ mod tests { let contour_id = ContourId::new(); assert!(matches!( - session.remove_contour(contour_id), + session.remove_contour_checked(contour_id), Err(CoreError::ContourNotFound(id)) if id == contour_id )); } @@ -989,7 +941,7 @@ mod tests { let anchor_id = add_anchor(&mut session, 10.0, 20.0); let missing_id = AnchorId::new(); - let result = session.move_anchors(&[anchor_id, missing_id], 5.0, 6.0); + let result = session.move_anchors_checked(&[anchor_id, missing_id], 5.0, 6.0); assert!(matches!( result, @@ -1052,10 +1004,7 @@ mod tests { let point_id = session .add_point_to_contour(contour_id, 10.0, 20.0, PointType::OnCurve, false) .unwrap(); - let anchor_id = - session - .layer_mut() - .add_anchor(Anchor::new(Some("top".to_string()), 30.0, 40.0)); + let anchor_id = session.add_anchor(Anchor::new(Some("top".to_string()), 30.0, 40.0)); session.translate_layer(5.0, -3.0); @@ -1064,7 +1013,7 @@ mod tests { .unwrap() .get_point(point_id) .unwrap(); - let anchor = session.layer().anchor(anchor_id).unwrap(); + let anchor = session.anchor(anchor_id).unwrap(); assert_eq!(point.x(), 15.0); assert_eq!(point.y(), 17.0); assert_eq!(anchor.x(), 35.0); @@ -1075,13 +1024,11 @@ mod tests { #[test] fn translate_layer_moves_component_transforms() { let mut session = create_session(); - let component_id = session - .layer_mut() - .add_component(Component::new("base".to_string())); + let component_id = session.add_component(Component::new("base".to_string())); session.translate_layer(12.0, -7.0); - let component = session.layer().component(component_id).unwrap(); + let component = session.component(component_id).unwrap(); let matrix = component.matrix(); assert_eq!(matrix.dx, 12.0); assert_eq!(matrix.dy, -7.0); diff --git a/crates/shift-font/src/lib.rs b/crates/shift-font/src/lib.rs index deaf0226..b8031a27 100644 --- a/crates/shift-font/src/lib.rs +++ b/crates/shift-font/src/lib.rs @@ -1,7 +1,15 @@ +pub mod composite; +pub mod curve; +pub mod error; pub mod ir; +pub mod layer_edit; +pub use error::{CoreError, CoreResult}; pub use ir::*; pub use ir::{ anchor, axis, boolean, component, contour, entity, features, font, glyph, glyph_name, guideline, kerning, layer, lib_data, metrics, point, segment, source, variation, }; +pub use layer_edit::{ + BulkNodePositionUpdates, ChangedEntities, EditableNode, PasteContour, PastePoint, PasteResult, +}; diff --git a/crates/shift-ir/Cargo.toml b/crates/shift-ir/Cargo.toml deleted file mode 100644 index 7d40b0b8..00000000 --- a/crates/shift-ir/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "shift-ir" -version = "0.1.0" -edition = "2021" -description = "Intermediate representation for font data - a rich, format-agnostic font model" -license = "MIT OR Apache-2.0" - -[lib] -crate-type = ["rlib"] - -[dependencies] -fontdrasil = "0.4.0" -indexmap = { version = "2", features = ["serde"] } -kurbo = "0.13.0" -linesweeper = "0.3.0" -serde = { version = "1.0", features = ["derive", "rc"] } diff --git a/crates/shift-ir/docs/DOCS.md b/crates/shift-ir/docs/DOCS.md deleted file mode 100644 index 8a15660e..00000000 --- a/crates/shift-ir/docs/DOCS.md +++ /dev/null @@ -1,134 +0,0 @@ -# shift-ir - -Format-agnostic intermediate representation for font data, serving as the canonical in-memory model for the Shift editor. - -## Architecture Invariants - -**Architecture Invariant:** All entity IDs are generated from a single global `ENTITY_COUNTER` (atomic u64 in `entity.rs`). This guarantees uniqueness across the entire application without coordination. Never construct IDs manually via `from_raw` in production code -- that bypass is for deserialization and tests only. - -**Architecture Invariant:** Typed ID newtypes (`PointId`, `ContourId`, `LayerId`, etc.) wrap `EntityId` to prevent accidental cross-type usage. A `PointId` cannot be passed where a `ContourId` is expected. WHY: font editing operations pass many IDs around; mixing them up would silently corrupt data. - -**Architecture Invariant:** `Font` always has exactly one default layer, created at construction time and stored as `default_layer_id`. The default layer uses the name `"public.default"`. WHY: every glyph operation assumes a default layer exists; removing or replacing it breaks the editing pipeline. - -**CRITICAL:** Glyphs are keyed by `GlyphName` (String) in `Font.glyphs`. If you rename a glyph via `set_name()` without re-inserting it, the HashMap key becomes stale and the glyph becomes unreachable by its new name. - -**Architecture Invariant:** Contours and components are stored in `HashMap` for O(1) lookup by ID, but anchors and guidelines are stored in `Vec` for order preservation. WHY: contour/component identity matters for selection and editing; anchor ordering matters for mark attachment semantics in OpenType. - -**Architecture Invariant:** `Component` stores a `DecomposedTransform` (translate, rotate, scale, skew, center) rather than a raw affine matrix. The raw `Transform` matrix is derived on demand via `to_matrix()`. WHY: decomposed form is what the UI manipulates; the matrix form is what rendering and file formats need. - -**Architecture Invariant:** IR types must remain format-agnostic. No UFO paths, binary table offsets, or format-specific metadata belong here. Format-specific concerns live in `shift-backends`. WHY: the IR is the single source of truth shared by all readers, writers, and the editor core. - -**Architecture Invariant:** `shift-ir` must not know about TypeScript or bridge bindings. Frontend-facing DTOs live in `shift-wire`, and TypeScript bridge declarations are generated from `shift-bridge`. WHY: IR is the Rust domain model, not a cross-language API contract. - -## Codemap - -``` -src/ - lib.rs -- public re-exports and GlyphName type alias - entity.rs -- ENTITY_COUNTER, EntityId, typed_id! macro - font.rs -- Font (root container), FontMetadata - metrics.rs -- FontMetrics (upm, ascender, descender, etc.) - glyph.rs -- Glyph, GlyphLayer (per-layer contours/components/anchors) - contour.rs -- Contour, Contours (BezPath interop) - point.rs -- Point, PointType (OnCurve, OffCurve, QCurve) - segment.rs -- CurveSegment, CurveSegmentIter - component.rs -- Component, Transform, DecomposedTransform - anchor.rs -- Anchor (named attachment point) - guideline.rs -- Guideline, GuidelineOrientation - layer.rs -- Layer (named layer definition) - kerning.rs -- KerningData, KerningPair, KerningSide - axis.rs -- Axis (design space axis), Location - source.rs -- Source (master at a design-space location) - features.rs -- FeatureData (.fea source storage) - boolean.rs -- boolean() function, BooleanOp (via linesweeper) - lib_data.rs -- LibData, LibValue (arbitrary plist-style storage) -``` - -## Key Types - -- `Font` -- root container holding metadata, metrics, layers, glyphs, kerning, features, guidelines, and lib data -- `Glyph` -- named glyph with unicode mappings and per-layer data (`HashMap`) -- `GlyphLayer` -- layer-specific data: contours (`HashMap`), components (`HashMap`), anchors (`Vec`), guidelines -- `Contour` -- ordered `Vec` with open/closed flag; converts to/from `kurbo::BezPath` -- `Point` -- position (f64, f64) with `PointType` and smooth flag -- `PointType` -- `OnCurve` (anchor on curve), `OffCurve` (cubic Bezier handle), `QCurve` (quadratic TrueType) -- `CurveSegment` / `CurveSegmentIter` -- typed iteration over point sequences as Line, Quad, or Cubic segments -- `Component` -- reference to another glyph by name with `DecomposedTransform` -- `DecomposedTransform` -- translate, rotate, scale, skew, center; composes to raw `Transform` matrix -- `Transform` -- raw 2x3 affine matrix -- `Layer` -- named layer with ID (default is `"public.default"`) -- `Anchor` -- named (x, y) position for mark attachment -- `KerningData` -- kerning pairs with group1/group2 support; lookup resolves groups -- `Axis` -- design space axis with tag, range, normalize/denormalize -- `Location` -- map of axis tag to value; normalizable against axes -- `Source` -- master at a `Location`, linked to a `LayerId` -- `LibData` / `LibValue` -- arbitrary key-value storage (plist semantics) - -## How it works - -**Ownership hierarchy:** `Font` uses copy-on-write storage around its document data and glyph map. Glyphs use copy-on-write layer storage. Each `GlyphLayer` owns contours (by `ContourId`), components (by `ComponentId`), and anchors (ordered `Vec`). This keeps save snapshots cheap while preserving normal value-style mutation APIs. - -**Edit pattern:** The upstream `shift-edit` crate edits a `GlyphLayer` through `EditSession`. The bridge owns active edit lifecycle and commits the edited layer back to the glyph/font when the session ends. - -**Segment iteration:** `Contour::segments()` returns a `CurveSegmentIter` that classifies consecutive points by their on-curve/off-curve pattern: two on-curve points produce a `Line`, on-off-on produces a `Quad`, on-off-off-on produces a `Cubic`. For closed contours, the iterator wraps around from the last point back to the first. - -**BezPath interop:** `Contour` converts to/from `kurbo::BezPath` for use with the `linesweeper` boolean operations and kurbo geometry utilities. The `Contours` newtype wraps `Vec` and implements `From<&BezPath>` to handle multi-subpath paths. - -**Variable fonts:** `Axis` defines a design space dimension. `Source` links a `Location` (axis coordinates) to a `LayerId`. `Axis::normalize()` / `denormalize()` map between user-space and normalized (-1..0..1) coordinates. - -## Workflow recipes - -### Add a new field to a core type - -1. Add the field to the struct (e.g., in `glyph.rs`) -2. Update `Default` impl if applicable -3. Add getter/setter methods -4. Update backend readers/writers in `shift-backends` to handle the new field -5. If the field crosses the bridge boundary, add or update the DTO in `shift-wire` - -### Add a new entity type - -1. Add `typed_id!(NewEntityId)` in `entity.rs` -2. Export it in `lib.rs` -3. Create the entity struct with an `id: NewEntityId` field -4. Use `NewEntityId::new()` in constructors (auto-increments from global counter) - -### Add boolean operations on contours - -1. Convert contours to `BezPath` using the `From` impl -2. Call `boolean(BooleanOp::Union, &a, &b)` (or other op) -3. Result is `Contours` (a `Vec` wrapper) -- each contour has fresh IDs - -### Perform a glyph edit (from shift-edit) - -1. `font.take_glyph("A")` to extract -2. Mutate the glyph's layer data -3. `font.put_glyph(glyph)` to return it - -## Gotchas - -- **Stale glyph keys:** `Glyph::set_name()` does not update the `Font` HashMap key. After renaming, you must `remove_glyph(old_name)` and `insert_glyph(renamed_glyph)`. -- **Anchor lookup is O(n):** Anchors are stored in a `Vec`, so `GlyphLayer::anchor(id)` is a linear scan. This is fine for typical glyph anchor counts (2-5) but would be a problem if anchor counts grew large. -- **DecomposedTransform roundtrip with skew:** `DecomposedTransform::from_matrix()` assumes no transformation center and may not perfectly roundtrip when skew is involved. -- **HashMap iteration order:** Contours and components are in `HashMap` -- iteration order is nondeterministic. If order matters (e.g., for file output), the backend must sort. -- **Global atomic counter:** `ENTITY_COUNTER` is a process-global `AtomicU64`. IDs are unique within a process but not across processes. Deserialized fonts get new IDs at load time. -- **`from_raw` accepts u128 but truncates to u64:** The `typed_id!` macro's `from_raw` takes `u128` and casts to `u64`. This is intentional for compatibility but can silently lose high bits. - -## Verification - -```bash -# Run all shift-ir tests -cargo test -p shift-ir - -# Verify downstream crates still compile -cargo check -p shift-edit -p shift-backends -``` - -## Related - -- `shift-edit` -- editing logic (`EditSession`, constraint enforcement) that operates on IR types -- `shift-backends` -- format readers/writers (UFO, Glyphs) that produce/consume `Font` -- `shift-wire` -- bridge DTOs derived from IR/edit state -- `shift-bridge` -- NAPI bindings exposing bridge DTOs to JavaScript/TypeScript -- `kurbo::BezPath` -- external type used for path geometry interop -- `linesweeper` -- external crate powering `boolean()` operations diff --git a/crates/shift-ir/src/anchor.rs b/crates/shift-ir/src/anchor.rs deleted file mode 100644 index ba6e8668..00000000 --- a/crates/shift-ir/src/anchor.rs +++ /dev/null @@ -1,98 +0,0 @@ -use crate::entity::AnchorId; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Anchor { - id: AnchorId, - name: Option, - x: f64, - y: f64, -} - -impl Anchor { - pub fn new(name: impl Into>, x: f64, y: f64) -> Self { - Self { - id: AnchorId::new(), - name: name.into(), - x, - y, - } - } - - pub fn with_id(id: AnchorId, name: impl Into>, x: f64, y: f64) -> Self { - Self { - id, - name: name.into(), - x, - y, - } - } - - pub fn id(&self) -> AnchorId { - self.id - } - - pub fn name(&self) -> Option<&str> { - self.name.as_deref() - } - - pub fn x(&self) -> f64 { - self.x - } - - pub fn y(&self) -> f64 { - self.y - } - - pub fn position(&self) -> (f64, f64) { - (self.x, self.y) - } - - pub fn set_position(&mut self, x: f64, y: f64) { - self.x = x; - self.y = y; - } - - pub fn set_name(&mut self, name: impl Into>) { - self.name = name.into(); - } - - pub fn translate(&mut self, dx: f64, dy: f64) { - self.x += dx; - self.y += dy; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn anchor_creation() { - let a = Anchor::new("top".to_string(), 250.0, 700.0); - assert_eq!(a.name(), Some("top")); - assert_eq!(a.x(), 250.0); - assert_eq!(a.y(), 700.0); - } - - #[test] - fn anchor_mutation() { - let mut a = Anchor::new("top".to_string(), 250.0, 700.0); - a.set_position(300.0, 750.0); - assert_eq!(a.position(), (300.0, 750.0)); - - a.translate(-50.0, 50.0); - assert_eq!(a.position(), (250.0, 800.0)); - - a.set_name(None::); - assert_eq!(a.name(), None); - } - - #[test] - fn anchor_with_id_roundtrip() { - let id = AnchorId::new(); - let a = Anchor::with_id(id, Some("top".to_string()), 1.0, 2.0); - assert_eq!(a.id(), id); - assert_eq!(a.name(), Some("top")); - } -} diff --git a/crates/shift-ir/src/axis.rs b/crates/shift-ir/src/axis.rs deleted file mode 100644 index 4d37fc01..00000000 --- a/crates/shift-ir/src/axis.rs +++ /dev/null @@ -1,179 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Axis { - tag: String, - name: String, - minimum: f64, - default: f64, - maximum: f64, - hidden: bool, -} - -impl Axis { - pub fn new(tag: String, name: String, minimum: f64, default: f64, maximum: f64) -> Self { - Self { - tag, - name, - minimum, - default, - maximum, - hidden: false, - } - } - - pub fn weight() -> Self { - Self::new( - "wght".to_string(), - "Weight".to_string(), - 100.0, - 400.0, - 900.0, - ) - } - - pub fn width() -> Self { - Self::new("wdth".to_string(), "Width".to_string(), 75.0, 100.0, 125.0) - } - - pub fn tag(&self) -> &str { - &self.tag - } - - pub fn name(&self) -> &str { - &self.name - } - - pub fn minimum(&self) -> f64 { - self.minimum - } - - pub fn default(&self) -> f64 { - self.default - } - - pub fn maximum(&self) -> f64 { - self.maximum - } - - pub fn is_hidden(&self) -> bool { - self.hidden - } - - pub fn set_hidden(&mut self, hidden: bool) { - self.hidden = hidden; - } - - pub fn normalize(&self, value: f64) -> f64 { - if value < self.default { - if (self.default - self.minimum).abs() < f64::EPSILON { - 0.0 - } else { - (value - self.default) / (self.default - self.minimum) - } - } else if value > self.default { - if (self.maximum - self.default).abs() < f64::EPSILON { - 0.0 - } else { - (value - self.default) / (self.maximum - self.default) - } - } else { - 0.0 - } - } - - pub fn denormalize(&self, value: f64) -> f64 { - if value < 0.0 { - self.default + value * (self.default - self.minimum) - } else if value > 0.0 { - self.default + value * (self.maximum - self.default) - } else { - self.default - } - } -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct Location { - values: HashMap, -} - -impl Location { - pub fn new() -> Self { - Self::default() - } - - pub fn from_map(values: HashMap) -> Self { - Self { values } - } - - pub fn get(&self, axis_tag: &str) -> Option { - self.values.get(axis_tag).copied() - } - - pub fn set(&mut self, axis_tag: String, value: f64) { - self.values.insert(axis_tag, value); - } - - pub fn is_empty(&self) -> bool { - self.values.is_empty() - } - - pub fn iter(&self) -> impl Iterator { - self.values.iter() - } - - pub fn normalize(&self, axes: &[Axis]) -> Location { - let mut normalized = HashMap::new(); - for axis in axes { - if let Some(&value) = self.values.get(axis.tag()) { - normalized.insert(axis.tag().to_string(), axis.normalize(value)); - } - } - Location::from_map(normalized) - } - - pub fn is_default_axis(&self, axes: &[Axis]) -> bool { - axes.iter().all(|axis| { - let value = self.get(axis.tag()).unwrap_or(axis.default()); - (value - axis.default()).abs() < f64::EPSILON - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn axis_normalize() { - let axis = Axis::weight(); - - assert_eq!(axis.normalize(400.0), 0.0); - assert!((axis.normalize(100.0) - (-1.0)).abs() < 0.001); - assert!((axis.normalize(900.0) - 1.0).abs() < 0.001); - assert!((axis.normalize(250.0) - (-0.5)).abs() < 0.001); - } - - #[test] - fn axis_denormalize() { - let axis = Axis::weight(); - - assert_eq!(axis.denormalize(0.0), 400.0); - assert_eq!(axis.denormalize(-1.0), 100.0); - assert_eq!(axis.denormalize(1.0), 900.0); - } - - #[test] - fn location_operations() { - let mut loc = Location::new(); - loc.set("wght".to_string(), 700.0); - loc.set("wdth".to_string(), 100.0); - - assert_eq!(loc.get("wght"), Some(700.0)); - assert_eq!(loc.get("wdth"), Some(100.0)); - assert_eq!(loc.get("slnt"), None); - } -} diff --git a/crates/shift-ir/src/boolean.rs b/crates/shift-ir/src/boolean.rs deleted file mode 100644 index 126ca99c..00000000 --- a/crates/shift-ir/src/boolean.rs +++ /dev/null @@ -1,116 +0,0 @@ -use crate::contour::{Contour, Contours}; -use kurbo::BezPath; -use linesweeper::binary_op; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BooleanOp { - Union, - Subtract, - Intersect, - Difference, -} - -pub fn boolean(op: BooleanOp, a: &Contour, b: &Contour) -> Result { - let path_a = BezPath::from(a); - let path_b = BezPath::from(b); - - let ls_op = match op { - BooleanOp::Union => linesweeper::BinaryOp::Union, - BooleanOp::Subtract => linesweeper::BinaryOp::Difference, - BooleanOp::Intersect => linesweeper::BinaryOp::Intersection, - BooleanOp::Difference => linesweeper::BinaryOp::Xor, - }; - - let out = binary_op(&path_a, &path_b, linesweeper::FillRule::EvenOdd, ls_op)?; - - let contours: Vec = out - .contours() - .flat_map(|c| Contours::from(&c.path).0) - .collect(); - - Ok(Contours(contours)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::point::PointType; - - fn square(x: f64, y: f64, size: f64) -> Contour { - let mut c = Contour::new(); - c.add_point(x, y, PointType::OnCurve, false); - c.add_point(x + size, y, PointType::OnCurve, false); - c.add_point(x + size, y + size, PointType::OnCurve, false); - c.add_point(x, y + size, PointType::OnCurve, false); - c.close(); - c - } - - #[test] - fn union_overlapping_squares() { - let a = square(0.0, 0.0, 100.0); - let b = square(50.0, 0.0, 100.0); - - let result = boolean(BooleanOp::Union, &a, &b).unwrap(); - assert_eq!(result.len(), 1); - assert!(result[0].is_closed()); - assert!(result[0].len() > 4); - } - - #[test] - fn intersect_overlapping_squares() { - let a = square(0.0, 0.0, 100.0); - let b = square(50.0, 0.0, 100.0); - - let result = boolean(BooleanOp::Intersect, &a, &b).unwrap(); - assert_eq!(result.len(), 1); - assert!(result[0].is_closed()); - } - - #[test] - fn subtract_overlapping_squares() { - let a = square(0.0, 0.0, 100.0); - let b = square(50.0, 0.0, 100.0); - - let result = boolean(BooleanOp::Subtract, &a, &b).unwrap(); - assert_eq!(result.len(), 1); - assert!(result[0].is_closed()); - } - - #[test] - fn difference_overlapping_squares() { - let a = square(0.0, 0.0, 100.0); - let b = square(50.0, 0.0, 100.0); - - let result = boolean(BooleanOp::Difference, &a, &b).unwrap(); - // XOR of two overlapping squares produces two separate regions - assert_eq!(result.len(), 2); - } - - #[test] - fn union_non_overlapping_produces_two() { - let a = square(0.0, 0.0, 50.0); - let b = square(200.0, 200.0, 50.0); - - let result = boolean(BooleanOp::Union, &a, &b).unwrap(); - assert_eq!(result.len(), 2); - } - - #[test] - fn intersect_non_overlapping_is_empty() { - let a = square(0.0, 0.0, 50.0); - let b = square(200.0, 200.0, 50.0); - - let result = boolean(BooleanOp::Intersect, &a, &b).unwrap(); - assert!(result.is_empty()); - } - - #[test] - fn union_identical_squares() { - let a = square(0.0, 0.0, 100.0); - let b = square(0.0, 0.0, 100.0); - - let result = boolean(BooleanOp::Union, &a, &b).unwrap(); - assert_eq!(result.len(), 1); - } -} diff --git a/crates/shift-ir/src/component.rs b/crates/shift-ir/src/component.rs deleted file mode 100644 index 5914f3c3..00000000 --- a/crates/shift-ir/src/component.rs +++ /dev/null @@ -1,365 +0,0 @@ -use crate::entity::ComponentId; -use crate::GlyphName; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Component { - id: ComponentId, - base_glyph: GlyphName, - transform: DecomposedTransform, -} - -/// Raw 2D affine transformation matrix (6 values). -/// -/// Represents a 3x3 homogeneous matrix with implicit bottom row [0, 0, 1]: -/// ```text -/// | xx yx dx | -/// | xy yy dy | -/// | 0 0 1 | (implicit) -/// ``` -#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] -pub struct Transform { - pub xx: f64, - pub xy: f64, - pub yx: f64, - pub yy: f64, - pub dx: f64, - pub dy: f64, -} - -/// Decomposed 2D transformation with explicit scale, rotation, skew, and translation. -/// Composition order: translate to center → rotate → scale → skew → translate back -#[derive(Clone, Copy, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DecomposedTransform { - pub translate_x: f64, - pub translate_y: f64, - pub rotation: f64, - pub scale_x: f64, - pub scale_y: f64, - pub skew_x: f64, - pub skew_y: f64, - pub t_center_x: f64, - pub t_center_y: f64, -} - -impl Default for DecomposedTransform { - fn default() -> Self { - Self { - translate_x: 0.0, - translate_y: 0.0, - rotation: 0.0, - scale_x: 1.0, - scale_y: 1.0, - skew_x: 0.0, - skew_y: 0.0, - t_center_x: 0.0, - t_center_y: 0.0, - } - } -} - -impl DecomposedTransform { - pub fn identity() -> Self { - Self::default() - } - - pub fn is_identity(&self) -> bool { - self.translate_x.abs() < f64::EPSILON - && self.translate_y.abs() < f64::EPSILON - && self.rotation.abs() < f64::EPSILON - && (self.scale_x - 1.0).abs() < f64::EPSILON - && (self.scale_y - 1.0).abs() < f64::EPSILON - && self.skew_x.abs() < f64::EPSILON - && self.skew_y.abs() < f64::EPSILON - } - - /// Compose the decomposed transform into a raw affine matrix. - /// - /// Order: translate to center → scale → skew → rotate → translate back → translate - pub fn to_matrix(&self) -> Transform { - let cos_r = self.rotation.to_radians().cos(); - let sin_r = self.rotation.to_radians().sin(); - let tan_sx = self.skew_x.to_radians().tan(); - let tan_sy = self.skew_y.to_radians().tan(); - - let xx = self.scale_x * cos_r + self.scale_y * tan_sx * sin_r; - let xy = self.scale_x * sin_r - self.scale_y * tan_sx * cos_r; - let yx = self.scale_y * -sin_r + self.scale_x * tan_sy * cos_r; - let yy = self.scale_y * cos_r + self.scale_x * tan_sy * sin_r; - - let center_x = self.t_center_x; - let center_y = self.t_center_y; - let dx = self.translate_x + center_x - (xx * center_x + yx * center_y); - let dy = self.translate_y + center_y - (xy * center_x + yy * center_y); - - Transform { - xx, - xy, - yx, - yy, - dx, - dy, - } - } - - /// Decompose a raw affine matrix into components. - /// - /// Note: This assumes no transformation center (t_center = 0, 0). - /// For matrices with skew, decomposition may not perfectly roundtrip. - pub fn from_matrix(m: &Transform) -> Self { - let scale_x = (m.xx * m.xx + m.xy * m.xy).sqrt(); - let scale_y = (m.yx * m.yx + m.yy * m.yy).sqrt(); - - let det = m.xx * m.yy - m.xy * m.yx; - let scale_y = if det < 0.0 { -scale_y } else { scale_y }; - - let rotation = m.xy.atan2(m.xx).to_degrees(); - - let skew_x = if scale_y.abs() > f64::EPSILON { - ((m.xx * m.yx + m.xy * m.yy) / (scale_x * scale_y)) - .atan() - .to_degrees() - } else { - 0.0 - }; - - Self { - translate_x: m.dx, - translate_y: m.dy, - rotation, - scale_x, - scale_y, - skew_x, - skew_y: 0.0, - t_center_x: 0.0, - t_center_y: 0.0, - } - } - - pub fn transform_point(&self, x: f64, y: f64) -> (f64, f64) { - self.to_matrix().transform_point(x, y) - } -} - -impl Transform { - pub fn identity() -> Self { - Self { - xx: 1.0, - xy: 0.0, - yx: 0.0, - yy: 1.0, - dx: 0.0, - dy: 0.0, - } - } - - pub fn translate(dx: f64, dy: f64) -> Self { - Self { - dx, - dy, - ..Self::identity() - } - } - - pub fn scale(sx: f64, sy: f64) -> Self { - Self { - xx: sx, - yy: sy, - ..Self::identity() - } - } - - /// Compose transforms as `self ∘ other` (apply `other`, then `self`). - pub fn compose(self, other: Self) -> Self { - Self { - xx: self.xx * other.xx + self.yx * other.xy, - xy: self.xy * other.xx + self.yy * other.xy, - yx: self.xx * other.yx + self.yx * other.yy, - yy: self.xy * other.yx + self.yy * other.yy, - dx: self.xx * other.dx + self.yx * other.dy + self.dx, - dy: self.xy * other.dx + self.yy * other.dy + self.dy, - } - } - - pub fn is_identity(&self) -> bool { - (self.xx - 1.0).abs() < f64::EPSILON - && self.xy.abs() < f64::EPSILON - && self.yx.abs() < f64::EPSILON - && (self.yy - 1.0).abs() < f64::EPSILON - && self.dx.abs() < f64::EPSILON - && self.dy.abs() < f64::EPSILON - } - - pub fn transform_point(&self, x: f64, y: f64) -> (f64, f64) { - ( - self.xx * x + self.yx * y + self.dx, - self.xy * x + self.yy * y + self.dy, - ) - } -} - -impl Component { - pub fn new(base_glyph: impl Into) -> Self { - Self { - id: ComponentId::new(), - base_glyph: base_glyph.into(), - transform: DecomposedTransform::identity(), - } - } - - pub fn with_transform( - base_glyph: impl Into, - transform: DecomposedTransform, - ) -> Self { - Self { - id: ComponentId::new(), - base_glyph: base_glyph.into(), - transform, - } - } - - pub fn with_id( - id: ComponentId, - base_glyph: impl Into, - transform: DecomposedTransform, - ) -> Self { - Self { - id, - base_glyph: base_glyph.into(), - transform, - } - } - - pub fn with_matrix(base_glyph: impl Into, matrix: &Transform) -> Self { - Self { - id: ComponentId::new(), - base_glyph: base_glyph.into(), - transform: DecomposedTransform::from_matrix(matrix), - } - } - - pub fn id(&self) -> ComponentId { - self.id - } - - pub fn base_glyph(&self) -> &GlyphName { - &self.base_glyph - } - - pub fn transform(&self) -> &DecomposedTransform { - &self.transform - } - - pub fn matrix(&self) -> Transform { - self.transform.to_matrix() - } - - pub fn set_transform(&mut self, transform: DecomposedTransform) { - self.transform = transform; - } - - pub fn translate(&mut self, dx: f64, dy: f64) { - self.transform.translate_x += dx; - self.transform.translate_y += dy; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn component_creation() { - let c = Component::new("a".to_string()); - assert_eq!(c.base_glyph().as_str(), "a"); - assert!(c.transform().is_identity()); - } - - #[test] - fn transform_point() { - let t = Transform::translate(100.0, 50.0); - let (x, y) = t.transform_point(10.0, 20.0); - assert_eq!(x, 110.0); - assert_eq!(y, 70.0); - } - - #[test] - fn transform_scale() { - let t = Transform::scale(2.0, 3.0); - let (x, y) = t.transform_point(10.0, 20.0); - assert_eq!(x, 20.0); - assert_eq!(y, 60.0); - } - - #[test] - fn transform_compose_order() { - let translate = Transform::translate(10.0, 20.0); - let scale = Transform::scale(2.0, 3.0); - let composed = translate.compose(scale); - let (x, y) = composed.transform_point(4.0, 5.0); - - assert_eq!(x, 18.0); - assert_eq!(y, 35.0); - } - - #[test] - fn decomposed_transform_identity() { - let d = DecomposedTransform::identity(); - assert!(d.is_identity()); - let m = d.to_matrix(); - assert!(m.is_identity()); - } - - #[test] - fn decomposed_transform_translate() { - let d = DecomposedTransform { - translate_x: 100.0, - translate_y: 50.0, - ..Default::default() - }; - let (x, y) = d.transform_point(10.0, 20.0); - assert!((x - 110.0).abs() < 1e-10); - assert!((y - 70.0).abs() < 1e-10); - } - - #[test] - fn decomposed_transform_scale() { - let d = DecomposedTransform { - scale_x: 2.0, - scale_y: 3.0, - ..Default::default() - }; - let (x, y) = d.transform_point(10.0, 20.0); - assert!((x - 20.0).abs() < 1e-10); - assert!((y - 60.0).abs() < 1e-10); - } - - #[test] - fn decomposed_transform_roundtrip() { - let original = DecomposedTransform { - translate_x: 50.0, - translate_y: 100.0, - rotation: 45.0, - scale_x: 2.0, - scale_y: 1.5, - ..Default::default() - }; - let matrix = original.to_matrix(); - let roundtrip = DecomposedTransform::from_matrix(&matrix); - - assert!((original.translate_x - roundtrip.translate_x).abs() < 1e-10); - assert!((original.translate_y - roundtrip.translate_y).abs() < 1e-10); - assert!((original.rotation - roundtrip.rotation).abs() < 1e-10); - assert!((original.scale_x - roundtrip.scale_x).abs() < 1e-10); - assert!((original.scale_y - roundtrip.scale_y).abs() < 1e-10); - } - - #[test] - fn component_translate() { - let mut c = Component::new("a".to_string()); - c.translate(10.0, 20.0); - assert!((c.transform().translate_x - 10.0).abs() < 1e-10); - assert!((c.transform().translate_y - 20.0).abs() < 1e-10); - } -} diff --git a/crates/shift-ir/src/contour.rs b/crates/shift-ir/src/contour.rs deleted file mode 100644 index c915a2ab..00000000 --- a/crates/shift-ir/src/contour.rs +++ /dev/null @@ -1,493 +0,0 @@ -use crate::entity::{ContourId, PointId}; -use crate::point::{Point, PointType}; -use crate::segment::CurveSegmentIter; -use kurbo::{BezPath, PathEl}; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Contour { - id: ContourId, - points: Vec, - closed: bool, -} - -impl Default for Contour { - fn default() -> Self { - Self::new() - } -} - -impl Contour { - pub fn new() -> Self { - Self { - id: ContourId::new(), - points: Vec::new(), - closed: false, - } - } - - pub fn with_id(id: ContourId) -> Self { - Self { - id, - points: Vec::new(), - closed: false, - } - } - - pub fn from_points(points: Vec, closed: bool) -> Self { - Self { - id: ContourId::new(), - points, - closed, - } - } - - pub fn id(&self) -> ContourId { - self.id - } - - pub fn points(&self) -> &[Point] { - &self.points - } - - pub fn points_mut(&mut self) -> &mut Vec { - &mut self.points - } - - pub fn is_closed(&self) -> bool { - self.closed - } - - pub fn is_empty(&self) -> bool { - self.points.is_empty() - } - - pub fn len(&self) -> usize { - self.points.len() - } - - pub fn close(&mut self) { - self.closed = true; - } - - pub fn open(&mut self) { - self.closed = false; - } - - pub fn reverse(&mut self) { - self.points.reverse(); - } - - pub fn add_point(&mut self, x: f64, y: f64, point_type: PointType, smooth: bool) -> PointId { - let id = PointId::new(); - let point = Point::new(id, x, y, point_type, smooth); - self.points.push(point); - id - } - - pub fn add_point_with_id( - &mut self, - id: PointId, - x: f64, - y: f64, - point_type: PointType, - smooth: bool, - ) { - let point = Point::new(id, x, y, point_type, smooth); - self.points.push(point); - } - - pub fn push_point(&mut self, point: Point) { - self.points.push(point); - } - - pub fn insert_point(&mut self, index: usize, point: Point) { - self.points.insert(index, point); - } - - pub fn get_point(&self, id: PointId) -> Option<&Point> { - self.points.iter().find(|p| p.id() == id) - } - - pub fn get_point_mut(&mut self, id: PointId) -> Option<&mut Point> { - self.points.iter_mut().find(|p| p.id() == id) - } - - pub fn get_point_at(&self, index: usize) -> Option<&Point> { - self.points.get(index) - } - - pub fn get_point_at_mut(&mut self, index: usize) -> Option<&mut Point> { - self.points.get_mut(index) - } - - pub fn remove_point(&mut self, id: PointId) -> Option { - let index = self.points.iter().position(|p| p.id() == id)?; - Some(self.points.remove(index)) - } - - pub fn insert_point_before( - &mut self, - before_id: PointId, - x: f64, - y: f64, - point_type: PointType, - smooth: bool, - ) -> Option { - let index = self.points.iter().position(|p| p.id() == before_id)?; - let id = PointId::new(); - let point = Point::new(id, x, y, point_type, smooth); - self.points.insert(index, point); - Some(id) - } - - pub fn point_index(&self, id: PointId) -> Option { - self.points.iter().position(|p| p.id() == id) - } - - pub fn first_point(&self) -> Option<&Point> { - self.points.first() - } - - pub fn last_point(&self) -> Option<&Point> { - self.points.last() - } - - pub fn segments(&self) -> CurveSegmentIter<'_> { - CurveSegmentIter::new(&self.points, self.closed) - } -} - -#[derive(Debug, Clone)] -pub struct Contours(pub Vec); - -impl std::ops::Deref for Contours { - type Target = Vec; - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl From<&BezPath> for Contours { - fn from(path: &BezPath) -> Self { - let mut contours = Vec::new(); - let mut current: Option = None; - - for el in path.elements() { - match *el { - PathEl::MoveTo(p) => { - if let Some(c) = current.take() { - contours.push(c); - } - let mut c = Contour::new(); - c.add_point(p.x, p.y, PointType::OnCurve, false); - current = Some(c); - } - PathEl::LineTo(p) => { - if let Some(c) = current.as_mut() { - c.add_point(p.x, p.y, PointType::OnCurve, false); - } - } - PathEl::QuadTo(ctrl, end) => { - if let Some(c) = current.as_mut() { - c.add_point(ctrl.x, ctrl.y, PointType::OffCurve, false); - c.add_point(end.x, end.y, PointType::OnCurve, false); - } - } - PathEl::CurveTo(c1, c2, end) => { - if let Some(c) = current.as_mut() { - c.add_point(c1.x, c1.y, PointType::OffCurve, false); - c.add_point(c2.x, c2.y, PointType::OffCurve, false); - c.add_point(end.x, end.y, PointType::OnCurve, false); - } - } - PathEl::ClosePath => { - if let Some(c) = current.as_mut() { - c.close(); - } - } - } - } - - if let Some(c) = current { - contours.push(c); - } - - Contours(contours) - } -} - -impl From<&Contour> for BezPath { - fn from(contour: &Contour) -> Self { - let mut path = BezPath::new(); - - if let Some(first) = contour.points().first() { - path.move_to((first.x(), first.y())); - } else { - return path; - } - - for segment in contour.segments() { - match segment { - crate::segment::CurveSegment::Line(_, p2) => { - path.line_to((p2.x(), p2.y())); - } - crate::segment::CurveSegment::Quad(_, c, p3) => { - path.quad_to((c.x(), c.y()), (p3.x(), p3.y())); - } - crate::segment::CurveSegment::Cubic(_, c1, c2, p4) => { - path.curve_to((c1.x(), c1.y()), (c2.x(), c2.y()), (p4.x(), p4.y())); - } - } - } - - if contour.is_closed() { - path.close_path(); - } - - path - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn contour_creation() { - let c = Contour::new(); - assert!(c.is_empty()); - assert!(!c.is_closed()); - } - - #[test] - fn add_points() { - let mut c = Contour::new(); - let id1 = c.add_point(10.0, 20.0, PointType::OnCurve, false); - let id2 = c.add_point(30.0, 40.0, PointType::OffCurve, false); - - assert_eq!(c.len(), 2); - assert!(c.get_point(id1).is_some()); - assert!(c.get_point(id2).is_some()); - } - - #[test] - fn remove_point() { - let mut c = Contour::new(); - let id = c.add_point(10.0, 20.0, PointType::OnCurve, false); - assert_eq!(c.len(), 1); - - let removed = c.remove_point(id); - assert!(removed.is_some()); - assert!(c.is_empty()); - } - - #[test] - fn close_contour() { - let mut c = Contour::new(); - assert!(!c.is_closed()); - c.close(); - assert!(c.is_closed()); - c.open(); - assert!(!c.is_closed()); - } - - use kurbo::PathEl; - - // -- BezPath conversion tests -- - - #[test] - fn empty_contour_to_bezpath() { - let c = Contour::new(); - let path = BezPath::from(&c); - assert_eq!(path.elements().len(), 0); - } - - #[test] - fn single_point_to_bezpath() { - let mut c = Contour::new(); - c.add_point(10.0, 20.0, PointType::OnCurve, false); - let path = BezPath::from(&c); - assert_eq!(path.elements().len(), 1); - assert!(matches!(path.elements()[0], PathEl::MoveTo(_))); - } - - #[test] - fn open_line_contour_to_bezpath() { - let mut c = Contour::new(); - c.add_point(0.0, 0.0, PointType::OnCurve, false); - c.add_point(100.0, 0.0, PointType::OnCurve, false); - c.add_point(100.0, 100.0, PointType::OnCurve, false); - - let path = BezPath::from(&c); - assert_eq!(path.elements().len(), 3); - assert!(matches!(path.elements()[0], PathEl::MoveTo(_))); - assert!(matches!(path.elements()[1], PathEl::LineTo(_))); - assert!(matches!(path.elements()[2], PathEl::LineTo(_))); - } - - #[test] - fn closed_triangle_to_bezpath() { - let mut c = Contour::new(); - c.add_point(0.0, 0.0, PointType::OnCurve, false); - c.add_point(100.0, 0.0, PointType::OnCurve, false); - c.add_point(50.0, 100.0, PointType::OnCurve, false); - c.close(); - - let path = BezPath::from(&c); - let els = path.elements(); - assert_eq!(els.len(), 5); - assert!(matches!(els[0], PathEl::MoveTo(_))); - assert!(matches!(els[1], PathEl::LineTo(_))); - assert!(matches!(els[2], PathEl::LineTo(_))); - assert!(matches!(els[3], PathEl::LineTo(_))); - assert!(matches!(els[4], PathEl::ClosePath)); - } - - #[test] - fn cubic_contour_to_bezpath() { - let mut c = Contour::new(); - c.add_point(0.0, 0.0, PointType::OnCurve, false); - c.add_point(33.0, 100.0, PointType::OffCurve, false); - c.add_point(66.0, 100.0, PointType::OffCurve, false); - c.add_point(100.0, 0.0, PointType::OnCurve, false); - - let path = BezPath::from(&c); - let els = path.elements(); - assert_eq!(els.len(), 2); - assert!(matches!(els[0], PathEl::MoveTo(_))); - assert!(matches!(els[1], PathEl::CurveTo(_, _, _))); - } - - #[test] - fn quad_contour_to_bezpath() { - let mut c = Contour::new(); - c.add_point(0.0, 0.0, PointType::OnCurve, false); - c.add_point(50.0, 100.0, PointType::OffCurve, false); - c.add_point(100.0, 0.0, PointType::OnCurve, false); - - let path = BezPath::from(&c); - let els = path.elements(); - assert_eq!(els.len(), 2); - assert!(matches!(els[0], PathEl::MoveTo(_))); - assert!(matches!(els[1], PathEl::QuadTo(_, _))); - } - - #[test] - fn mixed_segments_to_bezpath() { - let mut c = Contour::new(); - c.add_point(0.0, 0.0, PointType::OnCurve, false); - c.add_point(100.0, 0.0, PointType::OnCurve, false); - c.add_point(133.0, 50.0, PointType::OffCurve, false); - c.add_point(166.0, 50.0, PointType::OffCurve, false); - c.add_point(200.0, 0.0, PointType::OnCurve, false); - c.close(); - - let path = BezPath::from(&c); - let els = path.elements(); - // MoveTo + LineTo + CurveTo + LineTo(wrap) + ClosePath - assert!(matches!(els[0], PathEl::MoveTo(_))); - assert!(matches!(els[1], PathEl::LineTo(_))); - assert!(matches!(els[2], PathEl::CurveTo(_, _, _))); - assert!(matches!(els.last().unwrap(), PathEl::ClosePath)); - } - - // -- BezPath -> Vec tests -- - - #[test] - fn bezpath_line_to_contour() { - let mut path = BezPath::new(); - path.move_to((0.0, 0.0)); - path.line_to((100.0, 0.0)); - path.line_to((100.0, 100.0)); - path.close_path(); - - let contours = Contours::from(&path); - assert_eq!(contours.len(), 1); - assert!(contours[0].is_closed()); - assert_eq!(contours[0].len(), 3); - } - - #[test] - fn bezpath_cubic_to_contour() { - let mut path = BezPath::new(); - path.move_to((0.0, 0.0)); - path.curve_to((33.0, 100.0), (66.0, 100.0), (100.0, 0.0)); - - let contours = Contours::from(&path); - assert_eq!(contours.len(), 1); - assert!(!contours[0].is_closed()); - // MoveTo point + 2 off-curve + 1 on-curve = 4 - assert_eq!(contours[0].len(), 4); - assert_eq!( - contours[0].get_point_at(0).unwrap().point_type(), - PointType::OnCurve - ); - assert_eq!( - contours[0].get_point_at(1).unwrap().point_type(), - PointType::OffCurve - ); - assert_eq!( - contours[0].get_point_at(2).unwrap().point_type(), - PointType::OffCurve - ); - assert_eq!( - contours[0].get_point_at(3).unwrap().point_type(), - PointType::OnCurve - ); - } - - #[test] - fn bezpath_multiple_subpaths_to_contours() { - let mut path = BezPath::new(); - path.move_to((0.0, 0.0)); - path.line_to((50.0, 0.0)); - path.close_path(); - path.move_to((100.0, 100.0)); - path.line_to((200.0, 100.0)); - path.close_path(); - - let contours = Contours::from(&path); - assert_eq!(contours.len(), 2); - assert!(contours[0].is_closed()); - assert!(contours[1].is_closed()); - } - - #[test] - fn bezpath_open_subpath_to_contour() { - let mut path = BezPath::new(); - path.move_to((10.0, 20.0)); - path.line_to((30.0, 40.0)); - - let contours = Contours::from(&path); - assert_eq!(contours.len(), 1); - assert!(!contours[0].is_closed()); - } - - #[test] - fn empty_bezpath_to_contours() { - let path = BezPath::new(); - let contours = Contours::from(&path); - assert!(contours.is_empty()); - } - - #[test] - fn bezpath_coordinates_are_correct() { - let mut c = Contour::new(); - c.add_point(10.0, 20.0, PointType::OnCurve, false); - c.add_point(30.0, 40.0, PointType::OnCurve, false); - - let path = BezPath::from(&c); - let els = path.elements(); - - let PathEl::MoveTo(p0) = els[0] else { - panic!("expected MoveTo"); - }; - assert_eq!(p0, kurbo::Point::new(10.0, 20.0)); - - let PathEl::LineTo(p1) = els[1] else { - panic!("expected LineTo"); - }; - assert_eq!(p1, kurbo::Point::new(30.0, 40.0)); - } -} diff --git a/crates/shift-ir/src/entity.rs b/crates/shift-ir/src/entity.rs deleted file mode 100644 index d8657ce4..00000000 --- a/crates/shift-ir/src/entity.rs +++ /dev/null @@ -1,118 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::sync::atomic::{AtomicU64, Ordering}; - -static ENTITY_COUNTER: AtomicU64 = AtomicU64::new(1); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct EntityId(u64); - -impl EntityId { - pub fn new() -> Self { - Self(ENTITY_COUNTER.fetch_add(1, Ordering::Relaxed)) - } - - pub fn raw(&self) -> u64 { - self.0 - } - - pub fn from_raw(raw: u64) -> Self { - Self(raw) - } -} - -impl Default for EntityId { - fn default() -> Self { - Self::new() - } -} - -impl std::fmt::Display for EntityId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -macro_rules! typed_id { - ($name:ident) => { - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] - pub struct $name(EntityId); - - impl Default for $name { - fn default() -> Self { - Self::new() - } - } - - impl $name { - pub fn new() -> Self { - Self(EntityId::new()) - } - - pub fn raw(&self) -> u64 { - self.0.raw() - } - - pub fn from_raw(raw: u128) -> Self { - Self(EntityId::from_raw(raw as u64)) - } - } - - impl From<$name> for u64 { - fn from(id: $name) -> u64 { - id.raw() - } - } - - impl std::fmt::Display for $name { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } - } - - impl std::str::FromStr for $name { - type Err = std::num::ParseIntError; - - fn from_str(s: &str) -> Result { - let raw: u64 = s.parse()?; - Ok(Self::from_raw(raw as u128)) - } - } - }; -} - -typed_id!(PointId); -typed_id!(ContourId); -typed_id!(ComponentId); -typed_id!(AnchorId); -typed_id!(GuidelineId); -typed_id!(LayerId); -typed_id!(GlyphId); -typed_id!(SourceId); - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn entity_id_is_unique() { - let id1 = EntityId::new(); - let id2 = EntityId::new(); - assert_ne!(id1, id2); - } - - #[test] - fn typed_id_from_raw_roundtrip() { - let original = PointId::new(); - let raw = original.raw(); - let reconstructed = PointId::from_raw(raw as u128); - assert_eq!(original, reconstructed); - } - - #[test] - fn typed_id_display_and_parse() { - let id = ContourId::from_raw(12345u128); - assert_eq!(id.to_string(), "12345"); - let parsed: ContourId = "12345".parse().unwrap(); - assert_eq!(id, parsed); - } -} diff --git a/crates/shift-ir/src/features.rs b/crates/shift-ir/src/features.rs deleted file mode 100644 index 11a78291..00000000 --- a/crates/shift-ir/src/features.rs +++ /dev/null @@ -1,49 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct FeatureData { - fea_source: Option, -} - -impl FeatureData { - pub fn new() -> Self { - Self::default() - } - - pub fn from_fea(source: String) -> Self { - Self { - fea_source: Some(source), - } - } - - pub fn fea_source(&self) -> Option<&str> { - self.fea_source.as_deref() - } - - pub fn set_fea_source(&mut self, source: Option) { - self.fea_source = source; - } - - pub fn has_features(&self) -> bool { - self.fea_source.is_some() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn feature_data() { - let features = FeatureData::from_fea("feature liga { sub f i by fi; } liga;".to_string()); - assert!(features.has_features()); - assert!(features.fea_source().unwrap().contains("liga")); - } - - #[test] - fn empty_features() { - let features = FeatureData::new(); - assert!(!features.has_features()); - assert_eq!(features.fea_source(), None); - } -} diff --git a/crates/shift-ir/src/font.rs b/crates/shift-ir/src/font.rs deleted file mode 100644 index 3c638063..00000000 --- a/crates/shift-ir/src/font.rs +++ /dev/null @@ -1,577 +0,0 @@ -use crate::axis::{Axis, Location}; -use crate::entity::{LayerId, SourceId}; -use crate::features::FeatureData; -use crate::glyph::Glyph; -use crate::guideline::Guideline; -use crate::kerning::KerningData; -use crate::layer::Layer; -use crate::lib_data::LibData; -use crate::metrics::FontMetrics; -use crate::source::Source; -use crate::GlyphName; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FontMetadata { - pub family_name: Option, - pub style_name: Option, - pub version_major: Option, - pub version_minor: Option, - pub copyright: Option, - pub trademark: Option, - pub designer: Option, - pub designer_url: Option, - pub manufacturer: Option, - pub manufacturer_url: Option, - pub license: Option, - pub license_url: Option, - pub description: Option, - pub note: Option, -} - -impl Default for FontMetadata { - fn default() -> Self { - Self { - family_name: Some("Untitled Font".to_string()), - style_name: Some("Regular".to_string()), - version_major: Some(1), - version_minor: Some(0), - copyright: None, - trademark: None, - designer: None, - designer_url: None, - manufacturer: None, - manufacturer_url: None, - license: None, - license_url: None, - description: None, - note: None, - } - } -} - -impl FontMetadata { - pub fn new() -> Self { - Self::default() - } - - pub fn with_names(family_name: String, style_name: String) -> Self { - Self { - family_name: Some(family_name), - style_name: Some(style_name), - ..Self::default() - } - } - - pub fn display_name(&self) -> String { - match (&self.family_name, &self.style_name) { - (Some(family), Some(style)) => format!("{family} {style}"), - (Some(family), None) => family.clone(), - (None, Some(style)) => style.clone(), - (None, None) => "Untitled".to_string(), - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Font { - inner: Arc, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct FontData { - metadata: FontMetadata, - metrics: FontMetrics, - axes: Vec, - sources: Vec, - #[serde(default)] - default_source_id: Option, - layers: HashMap, - glyphs: HashMap>, - kerning: KerningData, - features: FeatureData, - guidelines: Vec, - lib: LibData, - default_layer_id: LayerId, -} - -impl Default for Font { - fn default() -> Self { - let default_layer_id = LayerId::new(); - let mut layers = HashMap::new(); - layers.insert(default_layer_id, Layer::default_layer()); - let default_source = Source::new("Regular".to_string(), Location::new(), default_layer_id); - let default_source_id = default_source.id(); - - Self { - inner: Arc::new(FontData { - metadata: FontMetadata::default(), - metrics: FontMetrics::default(), - axes: Vec::new(), - sources: vec![default_source], - default_source_id: Some(default_source_id), - layers, - glyphs: HashMap::new(), - kerning: KerningData::new(), - features: FeatureData::new(), - guidelines: Vec::new(), - lib: LibData::new(), - default_layer_id, - }), - } - } -} - -impl Font { - pub fn new() -> Self { - Self::default() - } - - pub fn empty() -> Self { - let default_layer_id = LayerId::new(); - let mut layers = HashMap::new(); - layers.insert(default_layer_id, Layer::default_layer()); - - Self { - inner: Arc::new(FontData { - metadata: FontMetadata::default(), - metrics: FontMetrics::default(), - axes: Vec::new(), - sources: Vec::new(), - default_source_id: None, - layers, - glyphs: HashMap::new(), - kerning: KerningData::new(), - features: FeatureData::new(), - guidelines: Vec::new(), - lib: LibData::new(), - default_layer_id, - }), - } - } - - fn data(&self) -> &FontData { - &self.inner - } - - fn data_mut(&mut self) -> &mut FontData { - Arc::make_mut(&mut self.inner) - } - - pub fn metadata(&self) -> &FontMetadata { - &self.data().metadata - } - - pub fn metadata_mut(&mut self) -> &mut FontMetadata { - &mut self.data_mut().metadata - } - - pub fn metrics(&self) -> &FontMetrics { - &self.data().metrics - } - - pub fn metrics_mut(&mut self) -> &mut FontMetrics { - &mut self.data_mut().metrics - } - - pub fn axes(&self) -> &[Axis] { - &self.data().axes - } - - pub fn add_axis(&mut self, axis: Axis) { - self.data_mut().axes.push(axis); - } - - pub fn sources(&self) -> &[Source] { - &self.data().sources - } - - pub fn add_source(&mut self, source: Source) -> SourceId { - let source_id = source.id(); - let data = self.data_mut(); - if data.default_source_id.is_none() { - data.default_source_id = Some(source_id); - } - data.sources.push(source); - source_id - } - - pub fn clear_sources(&mut self) { - let data = self.data_mut(); - data.sources.clear(); - data.default_source_id = None; - } - - pub fn default_source_id(&self) -> Option { - self.data().default_source_id - } - - pub fn set_default_source_id(&mut self, source_id: SourceId) { - self.data_mut().default_source_id = Some(source_id); - } - - pub fn default_source(&self) -> Option<&Source> { - let default_source_id = self.data().default_source_id?; - self.data() - .sources - .iter() - .find(|source| source.id() == default_source_id) - } - - pub fn is_variable(&self) -> bool { - !self.data().axes.is_empty() - } - - pub fn layers(&self) -> &HashMap { - &self.data().layers - } - - pub fn layer(&self, id: LayerId) -> Option<&Layer> { - self.data().layers.get(&id) - } - - pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> { - self.data_mut().layers.get_mut(&id) - } - - pub fn default_layer_id(&self) -> LayerId { - self.data().default_layer_id - } - - pub fn default_layer(&self) -> Option<&Layer> { - self.data().layers.get(&self.data().default_layer_id) - } - - pub fn add_layer(&mut self, layer: Layer) -> LayerId { - let id = layer.id(); - self.data_mut().layers.insert(id, layer); - id - } - - pub fn glyphs(&self) -> &HashMap> { - &self.data().glyphs - } - - pub fn glyph(&self, name: &str) -> Option<&Glyph> { - self.data().glyphs.get(name).map(Arc::as_ref) - } - - pub fn glyph_mut(&mut self, name: &str) -> Option<&mut Glyph> { - self.data_mut().glyphs.get_mut(name).map(Arc::make_mut) - } - - pub fn glyph_by_unicode(&self, unicode: u32) -> Option<&Glyph> { - self.data() - .glyphs - .values() - .find(|g| g.unicodes().contains(&unicode)) - .map(Arc::as_ref) - } - - pub fn glyph_by_unicode_mut(&mut self, unicode: u32) -> Option<&mut Glyph> { - self.data_mut() - .glyphs - .values_mut() - .find(|g| g.unicodes().contains(&unicode)) - .map(Arc::make_mut) - } - - pub fn insert_glyph(&mut self, glyph: Glyph) { - self.data_mut() - .glyphs - .insert(glyph.glyph_name().clone(), Arc::new(glyph)); - } - - pub fn remove_glyph(&mut self, name: &str) -> Option { - self.data_mut() - .glyphs - .remove(name) - .map(Arc::unwrap_or_clone) - } - - pub fn glyph_count(&self) -> usize { - self.data().glyphs.len() - } - - pub fn take_glyph(&mut self, name: &str) -> Option { - self.data_mut() - .glyphs - .remove(name) - .map(Arc::unwrap_or_clone) - } - - pub fn put_glyph(&mut self, glyph: Glyph) { - self.data_mut() - .glyphs - .insert(glyph.glyph_name().clone(), Arc::new(glyph)); - } - - pub fn kerning(&self) -> &KerningData { - &self.data().kerning - } - - pub fn kerning_mut(&mut self) -> &mut KerningData { - &mut self.data_mut().kerning - } - - pub fn features(&self) -> &FeatureData { - &self.data().features - } - - pub fn features_mut(&mut self) -> &mut FeatureData { - &mut self.data_mut().features - } - - pub fn guidelines(&self) -> &[Guideline] { - &self.data().guidelines - } - - pub fn add_guideline(&mut self, guideline: Guideline) { - self.data_mut().guidelines.push(guideline); - } - - pub fn lib(&self) -> &LibData { - &self.data().lib - } - - pub fn lib_mut(&mut self) -> &mut LibData { - &mut self.data_mut().lib - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Contour, GlyphLayer, PointType}; - use std::sync::Arc; - use std::time::{Duration, Instant}; - - #[derive(Clone, Copy)] - struct PerfFontMark { - label: &'static str, - glyphs: usize, - contours_per_glyph: usize, - points_per_contour: usize, - } - - impl PerfFontMark { - fn total_points(self) -> usize { - self.glyphs * self.contours_per_glyph * self.points_per_contour - } - } - - fn synthetic_point_heavy_font(mark: PerfFontMark) -> Font { - let mut font = Font::new(); - let default_layer_id = font.default_layer_id(); - - for glyph_index in 0..mark.glyphs { - let mut glyph = Glyph::with_unicode(format!("g{glyph_index:05}"), glyph_index as u32); - let mut layer = GlyphLayer::with_width(500.0 + glyph_index as f64); - - for contour_index in 0..mark.contours_per_glyph { - let mut contour = Contour::new(); - for point_index in 0..mark.points_per_contour { - contour.add_point( - point_index as f64, - (glyph_index + contour_index + point_index) as f64, - PointType::OnCurve, - false, - ); - } - layer.add_contour(contour); - } - - glyph.set_layer(default_layer_id, layer); - font.insert_glyph(glyph); - } - - font - } - - fn print_perf_mark(operation: &str, mark: PerfFontMark, elapsed: Duration) { - eprintln!( - "perf_mark {operation} [{}]: {} glyphs / {} points in {:?}", - mark.label, - mark.glyphs, - mark.total_points(), - elapsed - ); - } - - #[test] - fn font_creation() { - let font = Font::new(); - assert_eq!(font.glyph_count(), 0); - assert!(font.default_layer().is_some()); - assert_eq!(font.sources().len(), 1); - assert_eq!(font.default_source().map(Source::name), Some("Regular")); - } - - #[test] - fn font_glyph_operations() { - let mut font = Font::new(); - let mut glyph = Glyph::with_unicode("A".to_string(), 65); - let layer = GlyphLayer::with_width(600.0); - glyph.set_layer(font.default_layer_id(), layer); - - font.insert_glyph(glyph); - - assert_eq!(font.glyph_count(), 1); - assert!(font.glyph("A").is_some()); - assert!(font.glyph_by_unicode(65).is_some()); - } - - #[test] - fn font_take_put_glyph() { - let mut font = Font::new(); - font.insert_glyph(Glyph::with_unicode("A".to_string(), 65)); - - let taken = font.take_glyph("A"); - assert!(taken.is_some()); - assert_eq!(font.glyph_count(), 0); - - font.put_glyph(taken.unwrap()); - assert_eq!(font.glyph_count(), 1); - } - - #[test] - fn cloned_font_shares_storage_until_mutated() { - let mut font = Font::new(); - let snapshot = font.clone(); - - assert!(Arc::ptr_eq(&font.inner, &snapshot.inner)); - - font.metadata_mut().family_name = Some("Edited".to_string()); - - assert!(!Arc::ptr_eq(&font.inner, &snapshot.inner)); - assert_eq!(font.metadata().family_name.as_deref(), Some("Edited")); - assert_eq!( - snapshot.metadata().family_name.as_deref(), - Some("Untitled Font") - ); - } - - #[test] - fn mutating_one_glyph_after_snapshot_keeps_other_glyphs_shared() { - let mut font = Font::new(); - font.insert_glyph(Glyph::with_unicode("A".to_string(), 65)); - font.insert_glyph(Glyph::with_unicode("B".to_string(), 66)); - let snapshot = font.clone(); - - font.glyph_mut("A") - .unwrap() - .set_unicodes(vec![0x41, 0x00C1]); - - assert_eq!(font.glyph("A").unwrap().unicodes(), &[0x41, 0x00C1]); - assert_eq!(snapshot.glyph("A").unwrap().unicodes(), &[0x41]); - assert!(!Arc::ptr_eq( - font.inner.glyphs.get("A").unwrap(), - snapshot.inner.glyphs.get("A").unwrap() - )); - assert!(Arc::ptr_eq( - font.inner.glyphs.get("B").unwrap(), - snapshot.inner.glyphs.get("B").unwrap() - )); - } - - #[test] - fn perf_mark_large_font_clone_is_cow_snapshot() { - let marks = [ - PerfFontMark { - label: "small-latin", - glyphs: 250, - contours_per_glyph: 2, - points_per_contour: 12, - }, - PerfFontMark { - label: "large-latin", - glyphs: 2_000, - contours_per_glyph: 4, - points_per_contour: 16, - }, - PerfFontMark { - label: "cjk-scale", - glyphs: 10_000, - contours_per_glyph: 2, - points_per_contour: 8, - }, - ]; - - for mark in marks { - let font = synthetic_point_heavy_font(mark); - let start = Instant::now(); - let snapshots: Vec<_> = (0..128).map(|_| font.clone()).collect(); - let elapsed = start.elapsed(); - - assert_eq!(font.glyph_count(), mark.glyphs); - for snapshot in &snapshots { - assert!(Arc::ptr_eq(&font.inner, &snapshot.inner)); - assert_eq!(snapshot.glyph_count(), font.glyph_count()); - } - - print_perf_mark("font.clone snapshots x128", mark, elapsed); - assert!( - elapsed < Duration::from_secs(1), - "COW snapshot creation should stay comfortably sub-second for {}; got {elapsed:?}", - mark.label - ); - } - } - - #[test] - fn perf_mark_large_font_mutating_one_glyph_preserves_unedited_glyph_sharing() { - let mark = PerfFontMark { - label: "cjk-scale", - glyphs: 10_000, - contours_per_glyph: 2, - points_per_contour: 8, - }; - let mut font = synthetic_point_heavy_font(mark); - let snapshot = font.clone(); - let default_layer_id = font.default_layer_id(); - let start = Instant::now(); - - font.glyph_mut("g00000") - .expect("target glyph should exist") - .layer_mut(default_layer_id) - .expect("target layer should exist") - .set_width(777.0); - - let elapsed = start.elapsed(); - - assert_eq!( - font.glyph("g00000") - .unwrap() - .layer(default_layer_id) - .unwrap() - .width(), - 777.0 - ); - assert_ne!( - snapshot - .glyph("g00000") - .unwrap() - .layer(snapshot.default_layer_id()) - .unwrap() - .width(), - 777.0 - ); - assert!(!Arc::ptr_eq( - font.inner.glyphs.get("g00000").unwrap(), - snapshot.inner.glyphs.get("g00000").unwrap() - )); - assert!(Arc::ptr_eq( - font.inner.glyphs.get("g00001").unwrap(), - snapshot.inner.glyphs.get("g00001").unwrap() - )); - - print_perf_mark("single glyph mutation after snapshot", mark, elapsed); - assert!( - elapsed < Duration::from_secs(1), - "single-glyph COW mutation should stay comfortably sub-second; got {elapsed:?}" - ); - } -} diff --git a/crates/shift-ir/src/glyph.rs b/crates/shift-ir/src/glyph.rs deleted file mode 100644 index 82fe86ad..00000000 --- a/crates/shift-ir/src/glyph.rs +++ /dev/null @@ -1,359 +0,0 @@ -use crate::anchor::Anchor; -use crate::component::Component; -use crate::contour::Contour; -use crate::entity::{AnchorId, ComponentId, ContourId, GlyphId, LayerId}; -use crate::guideline::Guideline; -use crate::lib_data::LibData; -use crate::GlyphName; -use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Glyph { - id: GlyphId, - name: GlyphName, - unicodes: Vec, - layers: HashMap>, - lib: LibData, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct GlyphLayer { - width: f64, - height: Option, - contours: IndexMap, - components: HashMap, - anchors: Vec, - guidelines: Vec, - lib: LibData, -} - -impl GlyphLayer { - pub fn new() -> Self { - Self::default() - } - - pub fn with_width(width: f64) -> Self { - Self { - width, - ..Self::default() - } - } - - pub fn width(&self) -> f64 { - self.width - } - - pub fn height(&self) -> Option { - self.height - } - - pub fn set_width(&mut self, width: f64) { - self.width = width; - } - - pub fn set_height(&mut self, height: Option) { - self.height = height; - } - - pub fn contours(&self) -> &IndexMap { - &self.contours - } - - pub fn contours_iter(&self) -> impl Iterator { - self.contours.values() - } - - pub fn contours_iter_mut(&mut self) -> impl Iterator { - self.contours.values_mut() - } - - pub fn contour(&self, id: ContourId) -> Option<&Contour> { - self.contours.get(&id) - } - - pub fn contour_mut(&mut self, id: ContourId) -> Option<&mut Contour> { - self.contours.get_mut(&id) - } - - pub fn add_contour(&mut self, contour: Contour) -> ContourId { - let id = contour.id(); - self.contours.insert(id, contour); - id - } - - pub fn remove_contour(&mut self, id: ContourId) -> Option { - self.contours.shift_remove(&id) - } - - pub fn clear_contours(&mut self) { - self.contours.clear(); - } - - pub fn components(&self) -> &HashMap { - &self.components - } - - pub fn components_iter(&self) -> impl Iterator { - self.components.values() - } - - pub fn component(&self, id: ComponentId) -> Option<&Component> { - self.components.get(&id) - } - - pub fn add_component(&mut self, component: Component) -> ComponentId { - let id = component.id(); - self.components.insert(id, component); - id - } - - pub fn remove_component(&mut self, id: ComponentId) -> Option { - self.components.remove(&id) - } - - pub fn clear_components(&mut self) { - self.components.clear(); - } - - pub fn anchors(&self) -> &[Anchor] { - &self.anchors - } - - pub fn anchors_iter(&self) -> impl Iterator { - self.anchors.iter() - } - - pub fn anchor(&self, id: AnchorId) -> Option<&Anchor> { - self.anchors.iter().find(|anchor| anchor.id() == id) - } - - pub fn anchor_mut(&mut self, id: AnchorId) -> Option<&mut Anchor> { - self.anchors.iter_mut().find(|anchor| anchor.id() == id) - } - - pub fn anchor_index(&self, id: AnchorId) -> Option { - self.anchors.iter().position(|anchor| anchor.id() == id) - } - - pub fn add_anchor(&mut self, anchor: Anchor) -> AnchorId { - let id = anchor.id(); - self.anchors.push(anchor); - id - } - - pub fn remove_anchor(&mut self, id: AnchorId) -> Option { - self.anchor_index(id) - .map(|index| self.anchors.remove(index)) - } - - pub fn clear_anchors(&mut self) { - self.anchors.clear(); - } - - pub fn set_anchor_position(&mut self, id: AnchorId, x: f64, y: f64) -> bool { - let Some(anchor) = self.anchor_mut(id) else { - return false; - }; - anchor.set_position(x, y); - true - } - - pub fn move_anchors(&mut self, ids: &[AnchorId], dx: f64, dy: f64) -> Vec { - let mut moved = Vec::new(); - for id in ids { - if let Some(anchor) = self.anchor_mut(*id) { - anchor.translate(dx, dy); - moved.push(*id); - } - } - moved - } - - pub fn guidelines(&self) -> &[Guideline] { - &self.guidelines - } - - pub fn add_guideline(&mut self, guideline: Guideline) { - self.guidelines.push(guideline); - } - - pub fn lib(&self) -> &LibData { - &self.lib - } - - pub fn lib_mut(&mut self) -> &mut LibData { - &mut self.lib - } - - pub fn is_empty(&self) -> bool { - self.contours.is_empty() && self.components.is_empty() && self.anchors.is_empty() - } -} - -impl Glyph { - pub fn new(name: impl Into) -> Self { - Self { - id: GlyphId::new(), - name: name.into(), - unicodes: Vec::new(), - layers: HashMap::new(), - lib: LibData::new(), - } - } - - pub fn with_unicode(name: impl Into, unicode: u32) -> Self { - Self { - id: GlyphId::new(), - name: name.into(), - unicodes: vec![unicode], - layers: HashMap::new(), - lib: LibData::new(), - } - } - - pub fn id(&self) -> GlyphId { - self.id - } - - pub fn name(&self) -> &str { - self.name.as_str() - } - - pub fn glyph_name(&self) -> &GlyphName { - &self.name - } - - pub fn set_name(&mut self, name: impl Into) { - self.name = name.into(); - } - - pub fn unicodes(&self) -> &[u32] { - &self.unicodes - } - - pub fn primary_unicode(&self) -> Option { - self.unicodes.first().copied() - } - - pub fn add_unicode(&mut self, unicode: u32) { - if !self.unicodes.contains(&unicode) { - self.unicodes.push(unicode); - } - } - - pub fn remove_unicode(&mut self, unicode: u32) { - self.unicodes.retain(|&u| u != unicode); - } - - pub fn set_unicodes(&mut self, unicodes: Vec) { - self.unicodes = unicodes; - } - - pub fn layers(&self) -> &HashMap> { - &self.layers - } - - pub fn layer(&self, id: LayerId) -> Option<&GlyphLayer> { - self.layers.get(&id).map(Arc::as_ref) - } - - pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut GlyphLayer> { - self.layers.get_mut(&id).map(Arc::make_mut) - } - - pub fn get_or_create_layer(&mut self, id: LayerId) -> &mut GlyphLayer { - Arc::make_mut(self.layers.entry(id).or_default()) - } - - pub fn set_layer(&mut self, id: LayerId, layer: GlyphLayer) { - self.layers.insert(id, Arc::new(layer)); - } - - pub fn remove_layer(&mut self, id: LayerId) -> Option { - self.layers.remove(&id).map(Arc::unwrap_or_clone) - } - - pub fn lib(&self) -> &LibData { - &self.lib - } - - pub fn lib_mut(&mut self) -> &mut LibData { - &mut self.lib - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::Anchor; - use std::sync::Arc; - - #[test] - fn glyph_creation() { - let g = Glyph::with_unicode("A".to_string(), 65); - assert_eq!(g.name(), "A"); - assert_eq!(g.primary_unicode(), Some(65)); - } - - #[test] - fn glyph_layer_operations() { - let mut g = Glyph::new("A".to_string()); - let layer_id = LayerId::new(); - - let layer = g.get_or_create_layer(layer_id); - layer.set_width(600.0); - - assert_eq!(g.layer(layer_id).unwrap().width(), 600.0); - } - - #[test] - fn cloned_glyph_shares_layers_until_one_layer_is_mutated() { - let mut glyph = Glyph::new("A".to_string()); - let first_layer_id = LayerId::new(); - let second_layer_id = LayerId::new(); - glyph.set_layer(first_layer_id, GlyphLayer::with_width(500.0)); - glyph.set_layer(second_layer_id, GlyphLayer::with_width(600.0)); - let snapshot = glyph.clone(); - - glyph - .layer_mut(first_layer_id) - .expect("first layer should exist") - .set_width(700.0); - - assert_eq!(glyph.layer(first_layer_id).unwrap().width(), 700.0); - assert_eq!(snapshot.layer(first_layer_id).unwrap().width(), 500.0); - assert!(!Arc::ptr_eq( - glyph.layers.get(&first_layer_id).unwrap(), - snapshot.layers.get(&first_layer_id).unwrap() - )); - assert!(Arc::ptr_eq( - glyph.layers.get(&second_layer_id).unwrap(), - snapshot.layers.get(&second_layer_id).unwrap() - )); - } - - #[test] - fn glyph_layer_contours() { - let mut layer = GlyphLayer::with_width(500.0); - assert!(layer.is_empty()); - - let contour = Contour::new(); - let id = layer.add_contour(contour); - - assert!(!layer.is_empty()); - assert!(layer.contour(id).is_some()); - } - - #[test] - fn glyph_layer_anchors_are_ordered() { - let mut layer = GlyphLayer::new(); - let a1 = layer.add_anchor(Anchor::new(Some("top".to_string()), 10.0, 20.0)); - let a2 = layer.add_anchor(Anchor::new(Some("bottom".to_string()), 30.0, 40.0)); - - let ids: Vec<_> = layer.anchors_iter().map(|a| a.id()).collect(); - assert_eq!(ids, vec![a1, a2]); - } -} diff --git a/crates/shift-ir/src/glyph_name.rs b/crates/shift-ir/src/glyph_name.rs deleted file mode 100644 index c50667e9..00000000 --- a/crates/shift-ir/src/glyph_name.rs +++ /dev/null @@ -1,103 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::borrow::Borrow; -use std::fmt; -use std::ops::Deref; - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(transparent)] -pub struct GlyphName(String); - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum GlyphNameError { - Empty, -} - -impl GlyphName { - pub fn new(value: impl Into) -> Result { - let value = value.into(); - if value.is_empty() { - return Err(GlyphNameError::Empty); - } - Ok(Self(value)) - } - - pub fn as_str(&self) -> &str { - &self.0 - } - - pub fn into_string(self) -> String { - self.0 - } -} - -impl From for GlyphName { - fn from(value: String) -> Self { - Self::new(value).expect("glyph name must not be empty") - } -} - -impl From<&str> for GlyphName { - fn from(value: &str) -> Self { - Self::from(value.to_string()) - } -} - -impl From for String { - fn from(value: GlyphName) -> Self { - value.0 - } -} - -impl AsRef for GlyphName { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -impl Borrow for GlyphName { - fn borrow(&self) -> &str { - self.as_str() - } -} - -impl Deref for GlyphName { - type Target = str; - - fn deref(&self) -> &Self::Target { - self.as_str() - } -} - -impl fmt::Display for GlyphName { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -impl fmt::Display for GlyphNameError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Empty => formatter.write_str("glyph name must not be empty"), - } - } -} - -impl std::error::Error for GlyphNameError {} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rejects_empty_names() { - assert_eq!(GlyphName::new(""), Err(GlyphNameError::Empty)); - } - - #[test] - fn borrows_as_str_for_map_lookup() { - let mut names = std::collections::HashMap::new(); - names.insert(GlyphName::from("A"), 1); - - assert_eq!(names.get("A"), Some(&1)); - } -} diff --git a/crates/shift-ir/src/guideline.rs b/crates/shift-ir/src/guideline.rs deleted file mode 100644 index a2640619..00000000 --- a/crates/shift-ir/src/guideline.rs +++ /dev/null @@ -1,122 +0,0 @@ -use crate::entity::GuidelineId; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum GuidelineOrientation { - Horizontal, - Vertical, - Angle, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Guideline { - id: GuidelineId, - x: Option, - y: Option, - angle: Option, - name: Option, - color: Option, -} - -impl Guideline { - pub fn horizontal(y: f64) -> Self { - Self { - id: GuidelineId::new(), - x: None, - y: Some(y), - angle: None, - name: None, - color: None, - } - } - - pub fn vertical(x: f64) -> Self { - Self { - id: GuidelineId::new(), - x: Some(x), - y: None, - angle: None, - name: None, - color: None, - } - } - - pub fn angled(x: f64, y: f64, angle: f64) -> Self { - Self { - id: GuidelineId::new(), - x: Some(x), - y: Some(y), - angle: Some(angle), - name: None, - color: None, - } - } - - pub fn id(&self) -> GuidelineId { - self.id - } - - pub fn x(&self) -> Option { - self.x - } - - pub fn y(&self) -> Option { - self.y - } - - pub fn angle(&self) -> Option { - self.angle - } - - pub fn name(&self) -> Option<&str> { - self.name.as_deref() - } - - pub fn color(&self) -> Option<&str> { - self.color.as_deref() - } - - pub fn orientation(&self) -> GuidelineOrientation { - match (self.x, self.y, self.angle) { - (None, Some(_), None) => GuidelineOrientation::Horizontal, - (Some(_), None, None) => GuidelineOrientation::Vertical, - _ => GuidelineOrientation::Angle, - } - } - - pub fn set_name(&mut self, name: Option) { - self.name = name; - } - - pub fn set_color(&mut self, color: Option) { - self.color = color; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn horizontal_guideline() { - let g = Guideline::horizontal(700.0); - assert_eq!(g.orientation(), GuidelineOrientation::Horizontal); - assert_eq!(g.y(), Some(700.0)); - assert_eq!(g.x(), None); - } - - #[test] - fn vertical_guideline() { - let g = Guideline::vertical(250.0); - assert_eq!(g.orientation(), GuidelineOrientation::Vertical); - assert_eq!(g.x(), Some(250.0)); - assert_eq!(g.y(), None); - } - - #[test] - fn angled_guideline() { - let g = Guideline::angled(100.0, 100.0, 45.0); - assert_eq!(g.orientation(), GuidelineOrientation::Angle); - assert_eq!(g.angle(), Some(45.0)); - } -} diff --git a/crates/shift-ir/src/kerning.rs b/crates/shift-ir/src/kerning.rs deleted file mode 100644 index 328ffd3f..00000000 --- a/crates/shift-ir/src/kerning.rs +++ /dev/null @@ -1,142 +0,0 @@ -use crate::GlyphName; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum KerningSide { - Glyph(GlyphName), - Group(String), -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct KerningPair { - pub first: KerningSide, - pub second: KerningSide, - pub value: f64, -} - -impl KerningPair { - pub fn new(first: KerningSide, second: KerningSide, value: f64) -> Self { - Self { - first, - second, - value, - } - } - - pub fn glyph_pair( - first: impl Into, - second: impl Into, - value: f64, - ) -> Self { - Self { - first: KerningSide::Glyph(first.into()), - second: KerningSide::Glyph(second.into()), - value, - } - } -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct KerningData { - pairs: Vec, - groups1: HashMap>, - groups2: HashMap>, -} - -impl KerningData { - pub fn new() -> Self { - Self::default() - } - - pub fn pairs(&self) -> &[KerningPair] { - &self.pairs - } - - pub fn add_pair(&mut self, pair: KerningPair) { - self.pairs.push(pair); - } - - pub fn get_kerning(&self, first: &str, second: &str) -> Option { - for pair in &self.pairs { - let first_matches = match &pair.first { - KerningSide::Glyph(g) => g.as_str() == first, - KerningSide::Group(group) => self - .groups1 - .get(group) - .map(|members| members.iter().any(|member| member.as_str() == first)) - .unwrap_or(false), - }; - - let second_matches = match &pair.second { - KerningSide::Glyph(g) => g.as_str() == second, - KerningSide::Group(group) => self - .groups2 - .get(group) - .map(|members| members.iter().any(|member| member.as_str() == second)) - .unwrap_or(false), - }; - - if first_matches && second_matches { - return Some(pair.value); - } - } - None - } - - pub fn groups1(&self) -> &HashMap> { - &self.groups1 - } - - pub fn groups2(&self) -> &HashMap> { - &self.groups2 - } - - pub fn set_group1(&mut self, name: String, members: Vec) { - self.groups1.insert(name, members); - } - - pub fn set_group2(&mut self, name: String, members: Vec) { - self.groups2.insert(name, members); - } - - pub fn is_empty(&self) -> bool { - self.pairs.is_empty() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn kerning_pair_lookup() { - let mut kerning = KerningData::new(); - kerning.add_pair(KerningPair::glyph_pair( - "A".to_string(), - "V".to_string(), - -50.0, - )); - - assert_eq!(kerning.get_kerning("A", "V"), Some(-50.0)); - assert_eq!(kerning.get_kerning("A", "B"), None); - } - - #[test] - fn kerning_group_lookup() { - let mut kerning = KerningData::new(); - kerning.set_group1( - "public.kern1.A".to_string(), - vec!["A".into(), "Aacute".into()], - ); - kerning.set_group2("public.kern2.V".to_string(), vec!["V".into(), "W".into()]); - kerning.add_pair(KerningPair::new( - KerningSide::Group("public.kern1.A".to_string()), - KerningSide::Group("public.kern2.V".to_string()), - -40.0, - )); - - assert_eq!(kerning.get_kerning("A", "V"), Some(-40.0)); - assert_eq!(kerning.get_kerning("Aacute", "W"), Some(-40.0)); - } -} diff --git a/crates/shift-ir/src/layer.rs b/crates/shift-ir/src/layer.rs deleted file mode 100644 index 4df763cb..00000000 --- a/crates/shift-ir/src/layer.rs +++ /dev/null @@ -1,86 +0,0 @@ -use crate::entity::LayerId; -use crate::lib_data::LibData; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Layer { - id: LayerId, - name: String, - color: Option, - lib: LibData, -} - -impl Layer { - pub fn new(name: String) -> Self { - Self { - id: LayerId::new(), - name, - color: None, - lib: LibData::new(), - } - } - - pub fn default_layer() -> Self { - Self::new("public.default".to_string()) - } - - pub fn with_id(id: LayerId, name: String) -> Self { - Self { - id, - name, - color: None, - lib: LibData::new(), - } - } - - pub fn id(&self) -> LayerId { - self.id - } - - pub fn name(&self) -> &str { - &self.name - } - - pub fn color(&self) -> Option<&str> { - self.color.as_deref() - } - - pub fn lib(&self) -> &LibData { - &self.lib - } - - pub fn lib_mut(&mut self) -> &mut LibData { - &mut self.lib - } - - pub fn set_name(&mut self, name: String) { - self.name = name; - } - - pub fn set_color(&mut self, color: Option) { - self.color = color; - } - - pub fn is_default(&self) -> bool { - self.name == "public.default" - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn layer_creation() { - let l = Layer::new("foreground".to_string()); - assert_eq!(l.name(), "foreground"); - assert_eq!(l.color(), None); - } - - #[test] - fn default_layer() { - let l = Layer::default_layer(); - assert!(l.is_default()); - assert_eq!(l.name(), "public.default"); - } -} diff --git a/crates/shift-ir/src/lib.rs b/crates/shift-ir/src/lib.rs deleted file mode 100644 index 65ef1ea3..00000000 --- a/crates/shift-ir/src/lib.rs +++ /dev/null @@ -1,40 +0,0 @@ -mod anchor; -mod axis; -mod boolean; -pub mod component; -mod contour; -mod entity; -mod features; -mod font; -mod glyph; -mod glyph_name; -mod guideline; -mod kerning; -mod layer; -mod lib_data; -mod metrics; -mod point; -mod segment; -mod source; -pub mod variation; - -pub use anchor::Anchor; -pub use axis::{Axis, Location}; -pub use boolean::{boolean, BooleanOp}; -pub use component::{Component, DecomposedTransform, Transform}; -pub use contour::{Contour, Contours}; -pub use entity::{ - AnchorId, ComponentId, ContourId, EntityId, GlyphId, GuidelineId, LayerId, PointId, SourceId, -}; -pub use features::FeatureData; -pub use font::{Font, FontMetadata}; -pub use glyph::{Glyph, GlyphLayer}; -pub use glyph_name::{GlyphName, GlyphNameError}; -pub use guideline::{Guideline, GuidelineOrientation}; -pub use kerning::{KerningData, KerningPair, KerningSide}; -pub use layer::Layer; -pub use lib_data::{LibData, LibValue}; -pub use metrics::FontMetrics; -pub use point::{Point, PointType}; -pub use segment::{CurveSegment, CurveSegmentIter}; -pub use source::Source; diff --git a/crates/shift-ir/src/lib_data.rs b/crates/shift-ir/src/lib_data.rs deleted file mode 100644 index b46c1c56..00000000 --- a/crates/shift-ir/src/lib_data.rs +++ /dev/null @@ -1,90 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct LibData { - data: HashMap, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(untagged)] -pub enum LibValue { - String(String), - Integer(i64), - Float(f64), - Boolean(bool), - Array(Vec), - Dict(HashMap), - Data(Vec), -} - -impl LibData { - pub fn new() -> Self { - Self { - data: HashMap::new(), - } - } - - pub fn is_empty(&self) -> bool { - self.data.is_empty() - } - - pub fn len(&self) -> usize { - self.data.len() - } - - pub fn get(&self, key: &str) -> Option<&LibValue> { - self.data.get(key) - } - - pub fn set(&mut self, key: String, value: LibValue) { - self.data.insert(key, value); - } - - pub fn remove(&mut self, key: &str) -> Option { - self.data.remove(key) - } - - pub fn keys(&self) -> impl Iterator { - self.data.keys() - } - - pub fn iter(&self) -> impl Iterator { - self.data.iter() - } - - pub fn into_inner(self) -> HashMap { - self.data - } - - pub fn from_map(data: HashMap) -> Self { - Self { data } - } -} - -impl From> for LibData { - fn from(data: HashMap) -> Self { - Self { data } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn lib_data_operations() { - let mut lib = LibData::new(); - assert!(lib.is_empty()); - - lib.set("key1".to_string(), LibValue::String("value1".to_string())); - lib.set("key2".to_string(), LibValue::Integer(42)); - - assert_eq!(lib.len(), 2); - assert!(matches!(lib.get("key1"), Some(LibValue::String(s)) if s == "value1")); - assert!(matches!(lib.get("key2"), Some(LibValue::Integer(42)))); - - lib.remove("key1"); - assert_eq!(lib.len(), 1); - } -} diff --git a/crates/shift-ir/src/metrics.rs b/crates/shift-ir/src/metrics.rs deleted file mode 100644 index 40fa11d5..00000000 --- a/crates/shift-ir/src/metrics.rs +++ /dev/null @@ -1,60 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Copy, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FontMetrics { - pub units_per_em: f64, - pub ascender: f64, - pub descender: f64, - pub cap_height: Option, - pub x_height: Option, - pub line_gap: Option, - pub italic_angle: Option, - pub underline_position: Option, - pub underline_thickness: Option, -} - -impl Default for FontMetrics { - fn default() -> Self { - Self { - units_per_em: 1000.0, - ascender: 800.0, - descender: -200.0, - cap_height: Some(700.0), - x_height: Some(500.0), - line_gap: None, - italic_angle: None, - underline_position: None, - underline_thickness: None, - } - } -} - -impl FontMetrics { - pub fn new(units_per_em: f64, ascender: f64, descender: f64) -> Self { - Self { - units_per_em, - ascender, - descender, - ..Self::default() - } - } - - pub fn em_height(&self) -> f64 { - self.ascender - self.descender - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_metrics() { - let m = FontMetrics::default(); - assert_eq!(m.units_per_em, 1000.0); - assert_eq!(m.ascender, 800.0); - assert_eq!(m.descender, -200.0); - assert_eq!(m.em_height(), 1000.0); - } -} diff --git a/crates/shift-ir/src/point.rs b/crates/shift-ir/src/point.rs deleted file mode 100644 index 512fd50e..00000000 --- a/crates/shift-ir/src/point.rs +++ /dev/null @@ -1,151 +0,0 @@ -use crate::entity::PointId; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Copy, Default, PartialEq, Eq, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum PointType { - #[default] - OnCurve, - OffCurve, - QCurve, -} - -impl std::str::FromStr for PointType { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "onCurve" => Ok(Self::OnCurve), - "offCurve" => Ok(Self::OffCurve), - "qCurve" => Ok(Self::QCurve), - _ => Err(format!("Invalid point type: {s}")), - } - } -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize)] -pub struct Point { - id: PointId, - x: f64, - y: f64, - point_type: PointType, - smooth: bool, -} - -impl Point { - pub fn new(id: PointId, x: f64, y: f64, point_type: PointType, smooth: bool) -> Self { - Self { - id, - x, - y, - point_type, - smooth, - } - } - - pub fn on_curve(x: f64, y: f64) -> Self { - Self::new(PointId::new(), x, y, PointType::OnCurve, false) - } - - pub fn off_curve(x: f64, y: f64) -> Self { - Self::new(PointId::new(), x, y, PointType::OffCurve, false) - } - - pub fn id(&self) -> PointId { - self.id - } - - pub fn x(&self) -> f64 { - self.x - } - - pub fn y(&self) -> f64 { - self.y - } - - pub fn point_type(&self) -> PointType { - self.point_type - } - - pub fn is_smooth(&self) -> bool { - self.smooth - } - - pub fn is_on_curve(&self) -> bool { - matches!(self.point_type, PointType::OnCurve | PointType::QCurve) - } - - pub fn distance(&self, x: f64, y: f64) -> f64 { - ((self.x - x).powi(2) + (self.y - y).powi(2)).sqrt() - } - - pub fn set_position(&mut self, x: f64, y: f64) { - self.x = x; - self.y = y; - } - - pub fn translate(&mut self, dx: f64, dy: f64) { - self.x += dx; - self.y += dy; - } - - pub fn set_smooth(&mut self, smooth: bool) { - self.smooth = smooth; - } - - pub fn toggle_smooth(&mut self) { - self.smooth = !self.smooth; - } - - pub fn set_point_type(&mut self, point_type: PointType) { - self.point_type = point_type; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn point_creation() { - let p = Point::on_curve(100.0, 200.0); - assert_eq!(p.x(), 100.0); - assert_eq!(p.y(), 200.0); - assert_eq!(p.point_type(), PointType::OnCurve); - assert!(!p.is_smooth()); - } - - #[test] - fn point_mutation() { - let mut p = Point::on_curve(10.0, 20.0); - p.set_position(30.0, 40.0); - assert_eq!(p.x(), 30.0); - assert_eq!(p.y(), 40.0); - - p.translate(5.0, -10.0); - assert_eq!(p.x(), 35.0); - assert_eq!(p.y(), 30.0); - } - - #[test] - fn point_type_from_str() { - assert_eq!("onCurve".parse::().unwrap(), PointType::OnCurve); - assert_eq!( - "offCurve".parse::().unwrap(), - PointType::OffCurve - ); - assert_eq!("qCurve".parse::().unwrap(), PointType::QCurve); - assert!("invalid".parse::().is_err()); - assert!("OnCurve".parse::().is_err()); - } - - #[test] - fn point_smooth() { - let mut p = Point::on_curve(0.0, 0.0); - assert!(!p.is_smooth()); - p.set_smooth(true); - assert!(p.is_smooth()); - p.toggle_smooth(); - assert!(!p.is_smooth()); - } -} diff --git a/crates/shift-ir/src/segment.rs b/crates/shift-ir/src/segment.rs deleted file mode 100644 index db9d04d6..00000000 --- a/crates/shift-ir/src/segment.rs +++ /dev/null @@ -1,186 +0,0 @@ -use crate::point::Point; - -/// A typed curve segment extracted from a contour's point list. -#[derive(Debug, Clone)] -pub enum CurveSegment<'a> { - Line(&'a Point, &'a Point), - Quad(&'a Point, &'a Point, &'a Point), - Cubic(&'a Point, &'a Point, &'a Point, &'a Point), -} - -/// Iterator that yields [`CurveSegment`]s from a point slice. -/// -/// Walks the point list and classifies consecutive points into line, -/// quadratic, or cubic segments based on their on-curve/off-curve types. -pub struct CurveSegmentIter<'a> { - points: &'a [Point], - closed: bool, - pos: usize, - limit: usize, -} - -impl<'a> CurveSegmentIter<'a> { - pub fn new(points: &'a [Point], closed: bool) -> Self { - let limit = if closed { - points.len() - } else { - points.len().saturating_sub(1) - }; - Self { - points, - closed, - pos: 0, - limit, - } - } - - fn get(&self, idx: usize) -> Option<&'a Point> { - if idx < self.points.len() { - Some(&self.points[idx]) - } else if self.closed && !self.points.is_empty() { - Some(&self.points[idx % self.points.len()]) - } else { - None - } - } -} - -impl<'a> Iterator for CurveSegmentIter<'a> { - type Item = CurveSegment<'a>; - - fn next(&mut self) -> Option { - while self.pos < self.limit { - let p1 = self.get(self.pos)?; - let p2 = self.get(self.pos + 1)?; - - // Line: on-curve → on-curve - if p1.is_on_curve() && p2.is_on_curve() { - self.pos += 1; - return Some(CurveSegment::Line(p1, p2)); - } - - // Quad or cubic: on-curve → off-curve → ... - if p1.is_on_curve() && !p2.is_on_curve() { - if let Some(p3) = self.get(self.pos + 2) { - if p3.is_on_curve() { - self.pos += 2; - return Some(CurveSegment::Quad(p1, p2, p3)); - } - if let Some(p4) = self.get(self.pos + 3) { - self.pos += 3; - return Some(CurveSegment::Cubic(p1, p2, p3, p4)); - } - } - } - - self.pos += 1; - } - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn on(x: f64, y: f64) -> Point { - Point::on_curve(x, y) - } - - fn off(x: f64, y: f64) -> Point { - Point::off_curve(x, y) - } - - #[test] - fn empty_points_yields_nothing() { - let points: Vec = vec![]; - let segs: Vec<_> = CurveSegmentIter::new(&points, false).collect(); - assert!(segs.is_empty()); - } - - #[test] - fn single_point_yields_nothing() { - let points = vec![on(0.0, 0.0)]; - let segs: Vec<_> = CurveSegmentIter::new(&points, false).collect(); - assert!(segs.is_empty()); - } - - #[test] - fn line_segments() { - let points = vec![on(0.0, 0.0), on(100.0, 0.0), on(100.0, 100.0)]; - let segs: Vec<_> = CurveSegmentIter::new(&points, false).collect(); - assert_eq!(segs.len(), 2); - assert!(matches!(segs[0], CurveSegment::Line(_, _))); - assert!(matches!(segs[1], CurveSegment::Line(_, _))); - } - - #[test] - fn quad_segment() { - let points = vec![on(0.0, 0.0), off(50.0, 100.0), on(100.0, 0.0)]; - let segs: Vec<_> = CurveSegmentIter::new(&points, false).collect(); - assert_eq!(segs.len(), 1); - assert!(matches!(segs[0], CurveSegment::Quad(_, _, _))); - } - - #[test] - fn cubic_segment() { - let points = vec![ - on(0.0, 0.0), - off(33.0, 100.0), - off(66.0, 100.0), - on(100.0, 0.0), - ]; - let segs: Vec<_> = CurveSegmentIter::new(&points, false).collect(); - assert_eq!(segs.len(), 1); - assert!(matches!(segs[0], CurveSegment::Cubic(_, _, _, _))); - } - - #[test] - fn mixed_segments() { - let points = vec![ - on(0.0, 0.0), - on(100.0, 0.0), - off(150.0, 50.0), - on(200.0, 0.0), - ]; - let segs: Vec<_> = CurveSegmentIter::new(&points, false).collect(); - assert_eq!(segs.len(), 2); - assert!(matches!(segs[0], CurveSegment::Line(_, _))); - assert!(matches!(segs[1], CurveSegment::Quad(_, _, _))); - } - - #[test] - fn closed_contour_wraps_around() { - // A closed triangle: 3 on-curve points should yield 3 line segments - let points = vec![on(0.0, 0.0), on(100.0, 0.0), on(50.0, 100.0)]; - let segs: Vec<_> = CurveSegmentIter::new(&points, true).collect(); - assert_eq!(segs.len(), 3); - assert!(matches!(segs[0], CurveSegment::Line(_, _))); - assert!(matches!(segs[1], CurveSegment::Line(_, _))); - assert!(matches!(segs[2], CurveSegment::Line(_, _))); - - // The last segment should wrap: points[2] → points[0] - if let CurveSegment::Line(a, b) = &segs[2] { - assert_eq!(a.x(), 50.0); - assert_eq!(b.x(), 0.0); - } else { - panic!("Expected Line segment"); - } - } - - #[test] - fn closed_cubic_wraps() { - let points = vec![ - on(0.0, 0.0), - off(33.0, 100.0), - off(66.0, 100.0), - on(100.0, 0.0), - off(133.0, -100.0), - off(166.0, -100.0), - ]; - let segs: Vec<_> = CurveSegmentIter::new(&points, true).collect(); - assert_eq!(segs.len(), 2); - assert!(matches!(segs[0], CurveSegment::Cubic(_, _, _, _))); - assert!(matches!(segs[1], CurveSegment::Cubic(_, _, _, _))); - } -} diff --git a/crates/shift-ir/src/source.rs b/crates/shift-ir/src/source.rs deleted file mode 100644 index 0c44f278..00000000 --- a/crates/shift-ir/src/source.rs +++ /dev/null @@ -1,84 +0,0 @@ -use crate::axis::Location; -use crate::entity::{LayerId, SourceId}; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Source { - id: SourceId, - name: String, - location: Location, - layer_id: LayerId, - filename: Option, -} - -impl Source { - pub fn new(name: String, location: Location, layer_id: LayerId) -> Self { - Self { - id: SourceId::new(), - name, - location, - layer_id, - filename: None, - } - } - - pub fn with_filename( - name: String, - location: Location, - layer_id: LayerId, - filename: String, - ) -> Self { - Self { - id: SourceId::new(), - name, - location, - layer_id, - filename: Some(filename), - } - } - - pub fn id(&self) -> SourceId { - self.id - } - - pub fn name(&self) -> &str { - &self.name - } - - pub fn location(&self) -> &Location { - &self.location - } - - pub fn layer_id(&self) -> LayerId { - self.layer_id - } - - pub fn filename(&self) -> Option<&str> { - self.filename.as_deref() - } - - pub fn set_name(&mut self, name: String) { - self.name = name; - } - - pub fn set_location(&mut self, location: Location) { - self.location = location; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn source_creation() { - let layer_id = LayerId::new(); - let mut location = Location::new(); - location.set("wght".to_string(), 400.0); - - let source = Source::new("Regular".to_string(), location, layer_id); - assert_eq!(source.name(), "Regular"); - assert_eq!(source.location().get("wght"), Some(400.0)); - } -} diff --git a/crates/shift-ir/src/variation.rs b/crates/shift-ir/src/variation.rs deleted file mode 100644 index 59851249..00000000 --- a/crates/shift-ir/src/variation.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::str::FromStr; - -use fontdrasil::{ - coords::{NormalizedCoord, NormalizedLocation}, - types::Tag, -}; - -use crate::{Axis, Location}; - -pub fn to_fd_location(loc: &Location, axes: &[Axis]) -> NormalizedLocation { - let mut result = NormalizedLocation::new(); - - for axis in axes { - let value = loc.get(axis.tag()).unwrap_or(axis.default()); - let n = axis.normalize(value); - let Ok(tag) = Tag::from_str(axis.tag()) else { - continue; - }; - - result.insert(tag, NormalizedCoord::new(n)); - } - - result -} diff --git a/crates/shift-wire/Cargo.toml b/crates/shift-wire/Cargo.toml index f22a4d7f..bc46631e 100644 --- a/crates/shift-wire/Cargo.toml +++ b/crates/shift-wire/Cargo.toml @@ -13,8 +13,9 @@ default = [] napi = ["dep:napi", "dep:napi-derive"] [dependencies] -shift-ir = { workspace = true } +shift-font = { workspace = true } +fontdrasil = "0.4.0" napi = { version = "=3.8.6", optional = true, default-features = false, features = [ "napi6", ] } diff --git a/crates/shift-wire/src/bridges/napi/mod.rs b/crates/shift-wire/src/bridges/napi/mod.rs index 07ab5563..f8b3b952 100644 --- a/crates/shift-wire/src/bridges/napi/mod.rs +++ b/crates/shift-wire/src/bridges/napi/mod.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use napi::bindgen_prelude::Float64Array; use napi_derive::napi; -use shift_ir::PointType as IrPointType; +use shift_font::PointType as IrPointType; use crate::{ AnchorData, Axis, AxisTent, ComponentData, ContourData, FontMetadata, FontMetrics, diff --git a/crates/shift-edit/src/interpolation.rs b/crates/shift-wire/src/interpolation.rs similarity index 98% rename from crates/shift-edit/src/interpolation.rs rename to crates/shift-wire/src/interpolation.rs index 806adb78..948d799c 100644 --- a/crates/shift-edit/src/interpolation.rs +++ b/crates/shift-wire/src/interpolation.rs @@ -4,13 +4,12 @@ use std::str::FromStr; use fontdrasil::coords::{NormalizedCoord, NormalizedLocation}; use fontdrasil::types::Tag; use fontdrasil::variations::VariationModel; -use shift_ir::Axis; -use shift_wire::{ +use shift_font::{Axis, Font, Glyph}; + +use crate::{ values_from_layer, AxisTent, GlyphMaster, GlyphStructure, GlyphVariationData, Location, }; -use crate::{Font, Glyph}; - #[derive(Debug, Clone)] pub struct SourceError { pub source_index: usize, @@ -261,10 +260,10 @@ pub fn get_glyph_variation_data( mod tests { use std::collections::HashMap; - use shift_wire::{ContourData, GlyphMaster, GlyphStructure, Location, PointData, PointType}; + use crate::{ContourData, GlyphMaster, GlyphStructure, Location, PointData, PointType}; use super::build_glyph_variation_data; - use shift_ir::Axis; + use shift_font::Axis; fn structure_with_smooth(smooth: bool) -> GlyphStructure { GlyphStructure { diff --git a/crates/shift-wire/src/lib.rs b/crates/shift-wire/src/lib.rs index 23824e1b..46169193 100644 --- a/crates/shift-wire/src/lib.rs +++ b/crates/shift-wire/src/lib.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use shift_ir::{ +use shift_font::{ Anchor as IrAnchor, AnchorId, Axis as IrAxis, Component as IrComponent, ComponentId, Contour as IrContour, ContourId, DecomposedTransform as IrTransform, FontMetadata as IrFontMetadata, FontMetrics as IrFontMetrics, Glyph as IrGlyph, GlyphLayer, @@ -15,6 +15,8 @@ use shift_ir::{ }; pub mod bridges; +pub mod interpolation; +pub mod state; /// Flat numeric glyph values ordered to match `GlyphStructure`. /// diff --git a/crates/shift-edit/src/state.rs b/crates/shift-wire/src/state.rs similarity index 96% rename from crates/shift-edit/src/state.rs rename to crates/shift-wire/src/state.rs index 02c4dab3..4bac7b70 100644 --- a/crates/shift-edit/src/state.rs +++ b/crates/shift-wire/src/state.rs @@ -2,12 +2,12 @@ use std::str::FromStr; -use crate::error::{CoreError, CoreResult}; -use shift_ir::{ +use crate::{AnchorData, ComponentData, ContourData, GlyphStructure, GlyphValue}; +use shift_font::{ Anchor as IrAnchor, AnchorId, Component as IrComponent, ComponentId, Contour as IrContour, - ContourId, DecomposedTransform as IrTransform, GlyphLayer, PointId, PointType as IrPointType, + ContourId, CoreError, CoreResult, DecomposedTransform as IrTransform, GlyphLayer, PointId, + PointType as IrPointType, }; -use shift_wire::{AnchorData, ComponentData, ContourData, GlyphStructure, GlyphValue}; pub fn layer_from_state( structure: &GlyphStructure, @@ -177,8 +177,8 @@ fn restore_components( #[cfg(test)] mod tests { use super::*; - use shift_ir::{Anchor, Component, DecomposedTransform}; - use shift_wire::values_from_layer; + use crate::values_from_layer; + use shift_font::{Anchor, Component, DecomposedTransform}; fn sample_layer() -> GlyphLayer { let mut layer = GlyphLayer::with_width(500.0); diff --git a/docs/architecture/index.md b/docs/architecture/index.md index f89e8cef..6e48b5e1 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -15,9 +15,8 @@ Central routing table for Shift's distributed documentation. Before creating new | Path pattern | Canonical doc | Purpose | | -------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| `crates/shift-edit/**` | [`crates/shift-edit/docs/DOCS.md`](../../crates/shift-edit/docs/DOCS.md) | Editing logic and composite helpers | | `crates/shift-backends/**` | [`crates/shift-backends/docs/DOCS.md`](../../crates/shift-backends/docs/DOCS.md) | Font format backends for reading/writing various font formats | -| `crates/shift-ir/**` | [`crates/shift-ir/docs/DOCS.md`](../../crates/shift-ir/docs/DOCS.md) | Format-agnostic intermediate representation for the font model | +| `crates/shift-font/**` | [`crates/shift-font/docs/DOCS.md`](../../crates/shift-font/docs/DOCS.md) | First-class Rust font object model and editing behavior | | `crates/shift-bridge/**` | [`crates/shift-bridge/docs/DOCS.md`](../../crates/shift-bridge/docs/DOCS.md) | NAPI bridge exposing Rust to Node.js/Electron | ### Desktop app — Electron shell diff --git a/package.json b/package.json index 2f7ead13..ebf55951 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "glyph-info:repl": "pnpm --filter @shift/glyph-info repl", "test": "turbo run test", "test:unit": "pnpm turbo run test --filter='!shift-bridge'", - "test:integration": "pnpm --filter shift-bridge run test && cargo test -p shift-edit --test font_loading --test round_trip", + "test:integration": "pnpm --filter shift-bridge run test && cargo test --workspace", "test:lint": "pnpm --filter @shift/desktop run lint:check", "test:typecheck": "pnpm typecheck", "test:perf": "pnpm --filter @shift/desktop exec vitest bench --run", diff --git a/packages/glyph-state/docs/DOCS.md b/packages/glyph-state/docs/DOCS.md index 56fcf522..7d514c00 100644 --- a/packages/glyph-state/docs/DOCS.md +++ b/packages/glyph-state/docs/DOCS.md @@ -33,7 +33,7 @@ packages/glyph-state/src/ ## How It Fits -Rust owns loading, persistence, edit sessions, ID allocation, boolean operations, and authoritative mutation. The bridge returns `GlyphStructure + values` for a source. This package turns that state into useful geometry. The renderer wraps these readers in signals and editor APIs. +Rust owns loading, persistence, ID allocation, boolean operations, and authoritative mutation. The bridge returns `GlyphStructure + values` for a source. This package turns that state into useful geometry. The renderer wraps these readers in signals and editor APIs. ```ts const geometry = new GlyphStateGeometry(state.structure, state.values); diff --git a/packages/types/src/bridge/generated.ts b/packages/types/src/bridge/generated.ts index a6a5bb78..ed006d70 100644 --- a/packages/types/src/bridge/generated.ts +++ b/packages/types/src/bridge/generated.ts @@ -31,31 +31,25 @@ export interface BridgeApi { isVariable(): boolean getAxes(): Array getSources(): Array - startEditSession(glyphHandle: GlyphHandle, sourceId: SourceId): void getPersistedVersion(): number isDirty(): boolean - endEditSession(): void - hasEditSession(): boolean - getEditingUnicode(): Unicode | null - getEditingGlyphName(): GlyphName | null - getEditingSourceId(): SourceId | null - setXAdvance(width: number): GlyphValueChange - translateLayer(dx: number, dy: number): GlyphValueChange - addPoint(contourId: ContourId, x: number, y: number, pointType: PointType, smooth: boolean): GlyphStructureChange - insertPointBefore(beforePointId: PointId, x: number, y: number, pointType: PointType, smooth: boolean): GlyphStructureChange - addContour(): GlyphStructureChange - openContour(contourId: ContourId): GlyphStructureChange - closeContour(contourId: ContourId): GlyphStructureChange - reverseContour(contourId: ContourId): GlyphStructureChange - applyBooleanOp(contourIdA: ContourId, contourIdB: ContourId, operation: string): GlyphStructureChange - removePoints(pointIds: Array): GlyphStructureChange - toggleSmooth(pointId: PointId): GlyphStructureChange + setXAdvance(glyphRef: GlyphLayerRef, width: number): GlyphValueChange + translateLayer(glyphRef: GlyphLayerRef, dx: number, dy: number): GlyphValueChange + addPoint(glyphRef: GlyphLayerRef, contourId: ContourId, x: number, y: number, pointType: PointType, smooth: boolean): GlyphStructureChange + insertPointBefore(glyphRef: GlyphLayerRef, beforePointId: PointId, x: number, y: number, pointType: PointType, smooth: boolean): GlyphStructureChange + addContour(glyphRef: GlyphLayerRef): GlyphStructureChange + openContour(glyphRef: GlyphLayerRef, contourId: ContourId): GlyphStructureChange + closeContour(glyphRef: GlyphLayerRef, contourId: ContourId): GlyphStructureChange + reverseContour(glyphRef: GlyphLayerRef, contourId: ContourId): GlyphStructureChange + applyBooleanOp(glyphRef: GlyphLayerRef, contourIdA: ContourId, contourIdB: ContourId, operation: string): GlyphStructureChange + removePoints(glyphRef: GlyphLayerRef, pointIds: Array): GlyphStructureChange + toggleSmooth(glyphRef: GlyphLayerRef, pointId: PointId): GlyphStructureChange /** * Bulk position sync. IDs use BigUint64Array to avoid lossy float packing. * Coords are interleaved [x0, y0, x1, y1, ...]. */ - applyPositionPatch(pointIds?: BigUint64Array | undefined | null, pointCoords?: Float64Array | undefined | null, anchorIds?: BigUint64Array | undefined | null, anchorCoords?: Float64Array | undefined | null): void - restoreState(structure: GlyphStructure, values: Float64Array): GlyphStructureChange + applyPositionPatch(glyphRef: GlyphLayerRef, pointIds?: BigUint64Array | undefined | null, pointCoords?: Float64Array | undefined | null, anchorIds?: BigUint64Array | undefined | null, anchorCoords?: Float64Array | undefined | null): void + restoreState(glyphRef: GlyphLayerRef, structure: GlyphStructure, values: Float64Array): GlyphStructureChange } export interface GlyphHandle { @@ -63,6 +57,11 @@ export interface GlyphHandle { unicode?: Unicode } +export interface GlyphLayerRef { + glyphHandle: GlyphHandle + layerId: LayerId +} + export interface FontExportRequest { path: string format: string diff --git a/packages/types/src/bridge/index.ts b/packages/types/src/bridge/index.ts index 31571a25..82a267d5 100644 --- a/packages/types/src/bridge/index.ts +++ b/packages/types/src/bridge/index.ts @@ -9,6 +9,7 @@ export type { FontMetrics, GlyphChangedEntities, GlyphHandle, + GlyphLayerRef, GlyphMaster, GlyphName, GlyphRecord, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c804b3ec..7c1c7c52 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -40,6 +40,7 @@ export type { FontMetrics, GlyphChangedEntities, GlyphHandle, + GlyphLayerRef, GlyphMaster, GlyphName, GlyphRecord, diff --git a/scripts/context-drift-check.py b/scripts/context-drift-check.py index e9d6d4e2..7719947f 100755 --- a/scripts/context-drift-check.py +++ b/scripts/context-drift-check.py @@ -29,9 +29,8 @@ # All known DOCS.md locations (must match routing index) EXPECTED_DOCS = [ - "crates/shift-edit/docs/DOCS.md", "crates/shift-backends/docs/DOCS.md", - "crates/shift-ir/docs/DOCS.md", + "crates/shift-font/docs/DOCS.md", "crates/shift-bridge/docs/DOCS.md", "apps/desktop/src/main/docs/DOCS.md", "apps/desktop/src/preload/docs/DOCS.md", diff --git a/scripts/watch.sh b/scripts/watch.sh index 096213e8..80780867 100755 --- a/scripts/watch.sh +++ b/scripts/watch.sh @@ -14,9 +14,9 @@ cleanup() { trap cleanup EXIT INT TERM cargo watch \ - -w "crates/shift-edit/src/" \ + -w "crates/shift-font/src/" \ -w "crates/shift-bridge/src/" \ - -w "crates/shift-edit/Cargo.toml" \ + -w "crates/shift-font/Cargo.toml" \ -w "crates/shift-bridge/Cargo.toml" \ -w "Cargo.toml" \ -s "pnpm run build:native:debug && touch apps/desktop/src/main/main.ts"