Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ Use these root commands instead of invoking a package's Vitest script directly.
- Domain types belong in `/types/{domain}.ts`, not in implementation files
- NEVER define types (interfaces, type aliases, enums) directly in classes or service files
- Types should be imported from dedicated type files
- NEVER write derived utility types such as `Pick<Contour, "points" | "closed">` or `Omit<...>` inline in a function signature or body. Give the contract its own named type declaration in the appropriate domain type file and import it.
- Re-export types from their domain's index.ts for public API
- NEVER re-declare types that exist in `@shift/types` (generated from Rust). Import from `@shift/types`; for derived views (e.g. readonly, nested) use the domain pattern in `packages/types/src/domain.ts`

Expand Down
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,9 +539,9 @@ These are allowed to jump around when energy is high, but they should not silent

**Data Model**

- [ ] ComponentRef structure (glyph reference, transform, metrics flag)
- [ ] Add components array to Glyph struct
- [ ] Snapshot serialization for components
- [x] ComponentRef structure (glyph reference and transform)
- [x] Add components array to Glyph struct
- [x] Snapshot serialization for components

**Component Creation**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ function GlyphCell({
height: number;
view: GlyphView;
}) {
const svgPath = useSignalState(view.render.outline.svgPathCell, { schedule: "frame" });
const svgPath = useSignalState(view.svgPathCell, { schedule: "frame" });
const advance = useSignalState(view.xAdvanceCell, { schedule: "frame" });

const cellWidth = computeCellWidth(metrics, advance, height);
Expand Down
28 changes: 24 additions & 4 deletions apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { GlyphSidebearings } from "@/lib/model/Glyph";
import { computed, useSignalState } from "@/lib/signals";
import { useEditor } from "@/workspace/WorkspaceContext";
import { useMemo } from "react";

const EMPTY_SIDEBEARINGS: GlyphSidebearings = { lsb: null, rsb: null };

Expand All @@ -17,8 +20,25 @@ export interface GlyphSidebearingsState {
* @returns Current values and whether the displayed instance can be edited.
*/
export function useGlyphSidebearings(): GlyphSidebearingsState {
return {
sidebearings: EMPTY_SIDEBEARINGS,
hasLayer: false,
};
const editor = useEditor();
const sidebearingsCell = useMemo(
() =>
computed(() => {
const glyphNodes = editor.scene.cell.value.nodes.filter((node) => node.kind === "glyph");
const node = glyphNodes.length === 1 ? glyphNodes[0] : null;
if (!node) return { sidebearings: EMPTY_SIDEBEARINGS, hasLayer: false };

const location = editor.designLocationCell.value;
const view = editor.font.glyphView(node.glyphId, editor.designLocationCell);
if (!view) return { sidebearings: EMPTY_SIDEBEARINGS, hasLayer: false };

return {
sidebearings: view.sidebearingsCell.value,
hasLayer: editor.font.editableLayerAt(node.glyphId, location) !== null,
};
}),
[editor],
);

return useSignalState(sidebearingsCell, { schedule: "frame" });
}
29 changes: 25 additions & 4 deletions apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { computed, useSignalState } from "@/lib/signals";
import { useEditor } from "@/workspace/WorkspaceContext";
import { useMemo } from "react";

export interface GlyphXAdvanceState {
readonly xAdvance: number;
readonly hasLayer: boolean;
Expand All @@ -7,8 +11,25 @@ export interface GlyphXAdvanceState {
* Current glyph xAdvance, live-updating. Returns `0` when no glyph is loaded.
*/
export function useGlyphXAdvance(): GlyphXAdvanceState {
return {
xAdvance: 0,
hasLayer: false,
};
const editor = useEditor();
const xAdvanceCell = useMemo(
() =>
computed(() => {
const glyphNodes = editor.scene.cell.value.nodes.filter((node) => node.kind === "glyph");
const node = glyphNodes.length === 1 ? glyphNodes[0] : null;
if (!node) return { xAdvance: 0, hasLayer: false };

const location = editor.designLocationCell.value;
const view = editor.font.glyphView(node.glyphId, editor.designLocationCell);
if (!view) return { xAdvance: 0, hasLayer: false };

return {
xAdvance: view.xAdvanceCell.value,
hasLayer: editor.font.editableLayerAt(node.glyphId, location) !== null,
};
}),
[editor],
);

return useSignalState(xAdvanceCell, { schedule: "frame" });
}
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/src/lib/editor/rendering/Outline.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { GlyphOutline } from "@/lib/model/GlyphOutline";
import type { GlyphView } from "@/lib/model/Glyph";
import type { Canvas } from "./Canvas";

export interface OutlineStroke {
Expand All @@ -13,8 +13,8 @@ export interface OutlineDrawOptions {

/** Draws glyph outline parts with canvas styling chosen by the caller. */
export class OutlineRenderer {
draw(canvas: Canvas, outline: GlyphOutline, options: OutlineDrawOptions): void {
const path = outline.drawPath;
draw(canvas: Canvas, view: GlyphView, options: OutlineDrawOptions): void {
const path = view.drawPath;
if (options.fill) canvas.fillPath(path, options.fill);
if (options.stroke) canvas.strokePath(path, options.stroke.color, options.stroke.widthPx);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Canvas } from "../Canvas";
import type { HandleState } from "@/types/graphics";
import { drawHandle } from "./handleDrawing";
import type { HandleStateSource } from "./handles/HandleItems";
import type { GlyphRenderAnchor } from "@/lib/model/GlyphRenderModel";
import type { GlyphRenderAnchor } from "@/types/glyphRender";

/**
* Draws glyph attachment anchors as diamond handles in UPM space.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Canvas } from "../Canvas";
import type { GlyphRenderContour } from "@/lib/model/GlyphRenderModel";
import type { GlyphRenderContour } from "@/types/glyphRender";
import { Validate } from "@shift/validation";

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { Canvas } from "../Canvas";
import type { GlyphViewGeometry } from "@/lib/model/Glyph";
import type { GlyphView } from "@/lib/model/Glyph";
import type { SegmentId } from "@/types/indicator";

export class DebugOverlays {
draw(
canvas: Canvas,
geometry: GlyphViewGeometry,
view: GlyphView,
overlays: {
segmentBounds: boolean;
tightBounds: boolean;
Expand All @@ -18,21 +18,23 @@ export class DebugOverlays {
const { debug } = canvas.theme;

if (overlays.segmentBounds) {
this.#drawSegmentBounds(canvas, geometry, debug.segmentBounds);
this.#drawSegmentBounds(canvas, view, debug.segmentBounds);
}
if (overlays.tightBounds) {
this.#drawTightBounds(canvas, geometry, hoveredSegmentId, debug.tightBounds);
this.#drawTightBounds(canvas, view, hoveredSegmentId, debug.tightBounds);
}
if (overlays.hitRadii) {
this.#drawHitRadii(canvas, geometry, hitRadiusUpm, debug.hitRadii);
this.#drawHitRadii(canvas, view, hitRadiusUpm, debug.hitRadii);
}
if (overlays.glyphBbox) {
this.#drawGlyphBbox(canvas, geometry, debug.glyphBbox);
this.#drawGlyphBbox(canvas, view, debug.glyphBbox);
}
}

#drawSegmentBounds(canvas: Canvas, geometry: GlyphViewGeometry, color: string): void {
for (const contour of geometry.contours) {
#drawSegmentBounds(canvas: Canvas, view: GlyphView, color: string): void {
for (const contour of view.contours) {
if (contour.component) continue;

for (const segment of contour.segments()) {
const b = segment.bounds;
canvas.strokeRect(b.min.x, b.min.y, b.max.x - b.min.x, b.max.y - b.min.y, color, 1);
Expand All @@ -42,12 +44,14 @@ export class DebugOverlays {

#drawTightBounds(
canvas: Canvas,
geometry: GlyphViewGeometry,
view: GlyphView,
hoveredSegmentId: SegmentId | null,
color: string,
): void {
if (hoveredSegmentId === null) return;
for (const contour of geometry.contours) {
for (const contour of view.contours) {
if (contour.component) continue;

for (const segment of contour.segments()) {
if (segment.id !== hoveredSegmentId) continue;
const b = segment.bounds;
Expand All @@ -57,20 +61,15 @@ export class DebugOverlays {
}
}

#drawHitRadii(
canvas: Canvas,
geometry: GlyphViewGeometry,
hitRadiusUpm: number,
color: string,
): void {
#drawHitRadii(canvas: Canvas, view: GlyphView, hitRadiusUpm: number, color: string): void {
const r = hitRadiusUpm * canvas.camera.upmScale * canvas.camera.zoom;
for (const point of geometry.allPoints) {
for (const point of view.allPoints) {
canvas.strokeCircle({ x: point.x, y: point.y }, r, color, 1);
}
}

#drawGlyphBbox(canvas: Canvas, geometry: GlyphViewGeometry, color: string): void {
const b = geometry.bounds;
#drawGlyphBbox(canvas: Canvas, view: GlyphView, color: string): void {
const b = view.bounds;
if (!b) return;
canvas.strokeRect(b.min.x, b.min.y, b.max.x - b.min.x, b.max.y - b.min.y, color, 1);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { GlyphView } from "@/lib/model/Glyph";
import type { GlyphContour } from "@/lib/model/ComponentGlyph";
import type { Hover } from "@/lib/editor/Hover";
import type { Selection } from "@/lib/editor/Selection";
import type { GlyphNode } from "@/types/node";
Expand All @@ -21,14 +21,17 @@ export class Handles {
draw(
ctx: RenderContext,
node: GlyphNode,
view: GlyphView,
contours: readonly GlyphContour[],
selection: Selection,
hover: Hover,
): void {
const list = this.#items.fromContours(view.render.contours, {
selection,
hover,
});
const list = this.#items.fromContours(
contours.map((contour) => contour.contour),
{
selection,
hover,
},
);

if (this.#markers.draw(ctx.markers, list, ctx.canvas.camera, node.position)) return;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
import type { Canvas } from "../Canvas";
import type { Segment, SegmentId } from "@shift/glyph-state";
import type { GlyphViewGeometry } from "@/lib/model/Glyph";
import type { GlyphView } from "@/lib/model/Glyph";

export class Segments {
readonly #selected: Segment[] = [];

draw(
canvas: Canvas,
geometry: GlyphViewGeometry,
view: GlyphView,
selectedSegmentIds: readonly SegmentId[],
hoveredSegmentId: SegmentId | null,
): void {
this.#selected.length = 0;

for (const segmentId of selectedSegmentIds) {
const segment = geometry.segment(segmentId);
const segment = view.segment(segmentId);
if (segment) this.#selected.push(segment);
}

this.#drawResolved(
canvas,
hoveredSegmentId ? geometry.segment(hoveredSegmentId) : null,
hoveredSegmentId ? view.segment(hoveredSegmentId) : null,
this.#selected,
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { SelectableId } from "@/types";
import type { HandleState } from "@/types/graphics";
import type { Hover } from "@/lib/editor/Hover";
import type { Selection } from "@/lib/editor/Selection";
import type { GlyphRenderContour } from "@/lib/model/GlyphRenderModel";
import type { GlyphRenderContour } from "@/types/glyphRender";
import { PointHandleItem } from "./PointHandleItem";

export interface HandleStateSource {
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/src/renderer/src/lib/graphics/ContourPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { Mat } from "@shift/geo";
import { Contour } from "@shift/glyph-state";
import { asContourId, asPointId } from "@shift/types";
import { ContourPath } from "./ContourPath";

const mixedContour = new Contour(
{
id: asContourId("mixed"),
closed: false,
points: [
{ id: asPointId("p0"), pointType: "onCurve", smooth: false },
{ id: asPointId("p1"), pointType: "onCurve", smooth: false },
{ id: asPointId("p2"), pointType: "offCurve", smooth: false },
{ id: asPointId("p3"), pointType: "onCurve", smooth: false },
{ id: asPointId("p4"), pointType: "offCurve", smooth: false },
{ id: asPointId("p5"), pointType: "offCurve", smooth: false },
{ id: asPointId("p6"), pointType: "onCurve", smooth: false },
],
},
new Float64Array([0, 0, 10, 0, 15, 10, 20, 0, 25, -10, 35, 10, 40, 0]),
0,
);

describe("contour path output", () => {
it("shares transformed commands across SVG, Canvas, and bounds", () => {
const path = ContourPath.fromContour(mixedContour, Mat.Translate(5, -2));

expect(path.svgPath).toBe("M 5 -2 L 15 -2 Q 20 8 25 -2 C 30 -12 40 8 45 -2");
expect(path.bounds?.min.x).toBe(5);
expect(path.bounds?.min.y).toBeCloseTo(-4.886751);
expect(path.bounds?.max).toEqual({ x: 45, y: 3 });
expect(path.path).toBe(path.path);
});

it("represents contours without drawable segments as empty paths", () => {
const contour = new Contour(
{
id: asContourId("single"),
closed: true,
points: [{ id: asPointId("only"), pointType: "onCurve", smooth: false }],
},
new Float64Array([0, 0]),
0,
);
const path = ContourPath.fromContour(contour, Mat.Identity());

expect(path.commands).toEqual([]);
expect(path.svgPath).toBe("");
expect(path.bounds).toBeNull();
});
});
Loading
Loading