diff --git a/.codex/skills/shift-docs/SKILL.md b/.codex/skills/shift-docs/SKILL.md new file mode 100644 index 00000000..3a9dc00a --- /dev/null +++ b/.codex/skills/shift-docs/SKILL.md @@ -0,0 +1,21 @@ +--- +name: shift-docs +description: Route Shift documentation, architecture notes, and tickets correctly. Use when Codex is about to create or move docs, write an architecture plan, capture a design decision, create a ticket, or update repo documentation for Shift. +--- + +# Shift Docs + +Use the right destination for the artifact. + +## Routing + +- Stable repo documentation belongs in `docs/` or the module's canonical `DOCS.md`, following `docs/architecture/index.md`. +- Tickets, design scratchpads, future-work plans, and exploratory architecture proposals do not belong in repo `docs/`. +- Put tickets and exploratory plans in Obsidian under `~/Documents/KostyaVault/projects/shift/`, or open a GitHub issue when the user asks for a tracked issue. +- Do not create `CONTEXT.md` files. + +## Before Writing + +1. Read `docs/architecture/index.md` when changing stable repo documentation. +2. If the artifact is a ticket, plan, or unresolved proposal, route it to Obsidian or GitHub instead of `docs/`. +3. If unsure whether something is stable documentation or a ticket, ask briefly before writing. diff --git a/.codex/skills/shift-docs/agents/openai.yaml b/.codex/skills/shift-docs/agents/openai.yaml new file mode 100644 index 00000000..8ca86bfb --- /dev/null +++ b/.codex/skills/shift-docs/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Shift Docs" + short_description: "Route Shift documentation and tickets correctly." + default_prompt: "Use the Shift docs skill to decide where architecture notes, tickets, and repo docs should live." diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index 5b1dbeaa..92bc8bd7 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -66,12 +66,17 @@ import { ShiftStore } from "@/lib/store/ShiftStore"; import { EditorGesture, EditorInput, EditorViewState } from "./EditorState"; import type { PointerTarget } from "@/types/target"; import type { SelectableId, ShiftEditorRecord, ShiftId, ShiftObject } from "@/types"; -import type { GlyphNode } from "@/types/node"; +import type { GlyphNode, NodeKind } from "@/types/node"; import { AnchorObject, ContourObject, NodeObject, PointObject, SegmentObject } from "@/lib/objects"; +import type { NodeDefinition, NodeDefinitionConstructor } from "@/lib/nodes/NodeDefinition"; +import { GlyphNodeDefinition } from "../nodes/GlyphNodeDefinition"; + +const DEFAULT_NODE_DEFINITIONS: NodeDefinitionConstructor[] = [GlyphNodeDefinition]; interface EditorOptions { font: Font; clipboard: SystemClipboard; + nodeDefinitions?: readonly NodeDefinitionConstructor[]; } /** @@ -118,6 +123,7 @@ export class Editor { readonly hover: Hover; readonly font: Font; readonly scene: Scene; + readonly #nodeDefinitions: Map = new Map(); readonly #store: ShiftStore; /** @@ -186,6 +192,14 @@ export class Editor { this.#store = new ShiftStore(); this.scene = new Scene(this.#store); + const nodeDefs = new Map(); + for (const Def of options.nodeDefinitions ?? DEFAULT_NODE_DEFINITIONS) { + const def = new Def(this); + nodeDefs.set(def.kind, def); + } + + this.#nodeDefinitions = nodeDefs; + const initialDesignLocation = emptyAxisLocation(); this.#designLocation = signal(initialDesignLocation, { @@ -449,7 +463,7 @@ export class Editor { const node = this.scene.node(id); if (!node) return null; - return new NodeObject(node); + return new NodeObject(node, this.nodeDefinition(node.kind)); } if (isPointId(id)) { @@ -549,6 +563,10 @@ export class Editor { return null; } + nodeDefinition(kind: NodeKind): NodeDefinition | null { + return this.#nodeDefinitions.get(kind) ?? null; + } + /** * Resolves editor-addressable ids to objects. * @@ -700,52 +718,12 @@ export class Editor { const node = nodes[i]; if (!node) continue; - switch (node.kind) { - case "glyph": { - const nodePoint = this.getPointInNodeSpace(point, node.position); - const instance = this.font.instance(node.glyphId, this.designLocationCell); - if (!instance) break; - - const hit = instance.geometry.hitAt(nodePoint, this.hitRadius); - if (!hit) break; - - if (hit.kind === "segment") { - const segment = instance.geometry.segment(hit.id); - if (!segment) break; - - return { - ...hit, - nodeId: node.id, - glyphId: node.glyphId, - point: nodePoint, - segmentId: hit.id, - pointIds: segment.pointIds, - }; - } - - if (hit.kind === "point") { - return { - ...hit, - nodeId: node.id, - glyphId: node.glyphId, - point: nodePoint, - pointId: hit.id, - }; - } - - if (hit.kind === "anchor") { - return { - ...hit, - nodeId: node.id, - glyphId: node.glyphId, - point: nodePoint, - anchorId: hit.id, - }; - } + const definition = this.nodeDefinition(node.kind); + if (!definition) continue; - break; - } - } + const nodePoint = this.getPointInNodeSpace(point, node.position); + const target = definition.hit(node, nodePoint); + if (target) return target; } return { kind: "canvas", point }; diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/Canvas.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/Canvas.ts index b32b5857..e838b3c5 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/Canvas.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/Canvas.ts @@ -95,12 +95,12 @@ export class Canvas { } /** - * Run a drawing callback in glyph-local UPM coordinates. + * Runs a drawing callback in root scene coordinates. * - * @param drawOffset - Glyph-local offset applied after the camera transform. - * @param draw - Drawing operation to run while the context is in glyph space. + * @param drawOffset - Scene-space offset applied after the camera transform. + * @param draw - Drawing operation to run while the context is in scene space. */ - withGlyphSpace(drawOffset: Point2D, draw: (canvas: Canvas) => void): void { + withSceneSpace(drawOffset: Point2D, draw: (canvas: Canvas) => void): void { const camera = this.camera; this.ctx.save(); diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts index 31779f79..7a50adab 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts @@ -1,27 +1,10 @@ -import type { DebugOverlays as DebugOverlayState } from "@/types/uiState"; -import type { GlyphInstance } from "@/lib/model/Glyph"; -import type { Selection } from "@/lib/editor/Selection"; -import type { Hover } from "@/lib/editor/Hover"; import { displayAdvance } from "@/lib/utils/unicode"; -import { SCREEN_HIT_RADIUS } from "./constants"; import { CanvasItem } from "./CanvasItem"; import type { Canvas } from "./Canvas"; -import { OutlineRenderer } from "./Outline"; -import { Anchors, ControlLines, DebugOverlays, Guides, Handles, Segments } from "./overlays"; -import type { MarkerLayer } from "@/lib/graphics/backends/MarkerLayer"; -import type { GlyphNode } from "@/types/node"; +import { Guides } from "./overlays"; +import type { GlyphNode, ShiftNode } from "@/types/node"; +import type { RenderContext, RenderPass } from "@/types/rendering"; import type { Editor } from "../Editor"; -import type { SegmentId } from "@shift/glyph-state"; - -class GlyphFrame { - readonly node: GlyphNode; - readonly instance: GlyphInstance; - - constructor(node: GlyphNode, instance: GlyphInstance) { - this.node = node; - this.instance = instance; - } -} interface BackgroundGlyphFrame { readonly node: GlyphNode; @@ -32,19 +15,8 @@ export interface BackgroundLayerProps { readonly glyphs: readonly BackgroundGlyphFrame[]; } -interface SceneInteractionProps { - readonly selection: Selection; - readonly hover: Hover; -} - -interface SceneViewProps { - readonly debugOverlays: DebugOverlayState; -} - export interface SceneLayerProps { - readonly glyphs: readonly GlyphFrame[]; - readonly interaction: SceneInteractionProps; - readonly view: SceneViewProps; + readonly nodes: readonly ShiftNode[]; } export interface OverlayLayerProps { @@ -97,12 +69,11 @@ export class BackgroundLayer extends CanvasItem { ); if (!instance) break; - const frame = new GlyphFrame(node, instance); const unicode = record.unicodes[0] ?? null; glyphs.push({ - node: frame.node, - advance: displayAdvance(frame.instance.xAdvanceCell.value, record.name, unicode), + node, + advance: displayAdvance(instance.xAdvanceCell.value, record.name, unicode), }); break; } @@ -147,12 +118,6 @@ export class BackgroundLayer extends CanvasItem { */ export class SceneLayer extends CanvasItem { readonly #editor: Editor; - readonly #outline = new OutlineRenderer(); - readonly #debugOverlays = new DebugOverlays(); - readonly #controlLines = new ControlLines(); - readonly #anchors = new Anchors(); - readonly #segments = new Segments(); - readonly #handles: Handles; /** * Creates the scene layer for one editor. @@ -162,12 +127,6 @@ export class SceneLayer extends CanvasItem { constructor(editor: Editor) { super(); this.#editor = editor; - this.#handles = new Handles(); - } - - /** Attach the marker layer used by accelerated handle drawing. */ - setMarkerLayer(layer: MarkerLayer | null): void { - this.#handles.setMarkerLayer(layer); } protected props(): SceneLayerProps { @@ -179,158 +138,41 @@ export class SceneLayer extends CanvasItem { this.#editor.selection.stateCell.value; this.#editor.hover.entryCell.value; this.#editor.scene.cell.value; - - const glyphs: GlyphFrame[] = []; - for (const node of this.#editor.scene.nodes()) { - switch (node.kind) { - case "glyph": { - const instance = this.#editor.font.instance( - node.glyphId, - this.#editor.designLocationCell, - ); - if (!instance) break; - - const frame = new GlyphFrame(node, instance); - - frame.instance.render.trackShape(); - glyphs.push(frame); - break; - } - } - } + this.#editor.debugOverlaysCell.value; return { - glyphs, - interaction: { - selection: this.#editor.selection, - hover: this.#editor.hover, - }, - view: { - debugOverlays: this.#editor.debugOverlaysCell.value, - }, + nodes: this.#editor.scene.nodes(), }; } - draw(canvas: Canvas): void { + draw(ctx: RenderContext): void { const props = this.propsCell.value; if (!props) return; - for (const glyph of props.glyphs) { - this.#drawGlyphOutline(canvas, glyph); - this.#drawDebugOverlays(canvas, props, glyph); - canvas.withTranslation(glyph.node.position, () => { - this.#editor.toolManager.drawScene(canvas); - }); + for (const node of props.nodes) { + this.#drawNode(ctx, node, "content"); } - let drewHandles = false; - for (const glyph of props.glyphs) { - drewHandles = this.#drawGlyphEditHandles(canvas, props, glyph) || drewHandles; - } - if (!drewHandles) this.#handles.clear(); - } + for (const node of props.nodes) { + if (node.kind !== "glyph") continue; - #drawGlyphOutline(canvas: Canvas, glyph: GlyphFrame): void { - canvas.withTranslation(glyph.node.position, () => { - this.#outline.draw(canvas, glyph.instance.render.outline, { - fill: null, - stroke: { - color: canvas.theme.glyph.stroke, - widthPx: canvas.theme.glyph.widthPx, - }, + ctx.canvas.withTranslation(node.position, () => { + this.#editor.toolManager.drawScene(ctx.canvas); }); - }); - } - - #drawDebugOverlays(canvas: Canvas, props: SceneLayerProps, glyph: GlyphFrame): void { - const { hover } = props.interaction; - const hoveredObject = hover.entry ? this.#editor.object(hover.entry) : null; - const hoveredSegmentId = - hoveredObject?.kind === "segment" && hoveredObject.node.id === glyph.node.id - ? hoveredObject.segmentId - : null; - - canvas.withTranslation(glyph.node.position, () => { - this.#debugOverlays.draw( - canvas, - glyph.instance.geometry, - props.view.debugOverlays, - hoveredSegmentId, - canvas.pxToUpm(SCREEN_HIT_RADIUS), - ); - }); - } - - #selectedSegmentIds(glyph: GlyphFrame): readonly SegmentId[] { - const segmentIds: SegmentId[] = []; - - for (const object of this.#editor.objects(this.#editor.selection.ids)) { - if (object.kind !== "segment") continue; - if (object.node.id !== glyph.node.id) continue; - - segmentIds.push(object.segmentId); } - return segmentIds; + for (const node of props.nodes) { + this.#drawNode(ctx, node, "controls"); + } } - #hoveredSegmentId(glyph: GlyphFrame): SegmentId | null { - const id = this.#editor.hover.id; - if (!id) return null; - - const object = this.#editor.object(id); - if (object?.kind !== "segment") return null; - if (object.node.id !== glyph.node.id) return null; + #drawNode(ctx: RenderContext, node: ShiftNode, pass: RenderPass): void { + const definition = this.#editor.nodeDefinition(node.kind); + if (!definition) return; - return object.segmentId; - } - - #drawGlyphEditHandles(canvas: Canvas, props: SceneLayerProps, glyph: GlyphFrame): boolean { - const renderModel = glyph.instance.render; - const sceneBounds = this.#editor.camera.visibleSceneBounds(64); - const origin = glyph.node.position; - - canvas.withTranslation(origin, () => { - this.#segments.draw( - canvas, - glyph.instance.geometry, - this.#selectedSegmentIds(glyph), - this.#hoveredSegmentId(glyph), - ); + ctx.canvas.withTranslation(node.position, () => { + definition.draw(node, ctx, pass); }); - - canvas.withTranslation(origin, () => { - this.#controlLines.draw(canvas, renderModel.contours, (from, to) => { - const minX = Math.min(from.x, to.x) + origin.x; - const maxX = Math.max(from.x, to.x) + origin.x; - const minY = Math.min(from.y, to.y) + origin.y; - const maxY = Math.max(from.y, to.y) + origin.y; - return !( - maxX < sceneBounds.minX || - minX > sceneBounds.maxX || - maxY < sceneBounds.minY || - minY > sceneBounds.maxY - ); - }); - }); - - this.#handles.draw( - canvas, - canvas.camera, - glyph.node, - glyph.instance, - props.interaction.selection, - props.interaction.hover, - ); - - canvas.withTranslation(origin, () => { - this.#anchors.draw(canvas, renderModel.anchors, { - selection: props.interaction.selection, - hover: props.interaction.hover, - }); - }); - - return true; } } diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/Renderer.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/Renderer.ts index 1677561a..e9d35123 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/Renderer.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/Renderer.ts @@ -8,6 +8,7 @@ import type { Editor } from "../Editor"; import type { Canvas2DSurface, MarkerCanvasSurface } from "./CanvasSurface"; import { effect, signal, track, type Effect, type WritableSignal } from "@/lib/signals/signal"; import { BackgroundLayer, OverlayLayer, SceneLayer } from "./RenderFrame"; +import type { RenderContext } from "@/types/rendering"; type RenderLayer = "background" | "scene" | "overlay"; @@ -65,7 +66,6 @@ export class Renderer { this.#backgroundLayer = new BackgroundLayer(editor); this.#sceneLayer = new SceneLayer(editor); this.#overlayLayer = new OverlayLayer(editor); - this.#sceneLayer.setMarkerLayer(this.#markerLayer); this.#backgroundEffect = effect( () => { @@ -136,7 +136,6 @@ export class Renderer { this.#markerSurface.set(null); this.#markerLayer.destroy(); this.#markerLayer = new MarkerLayer(); - this.#sceneLayer.setMarkerLayer(this.#markerLayer); } get markerLayer(): MarkerLayer { @@ -147,7 +146,7 @@ export class Renderer { const canvas = this.#getCanvas("background"); if (!canvas) return; - canvas.withGlyphSpace({ x: 0, y: 0 }, () => { + canvas.withSceneSpace({ x: 0, y: 0 }, () => { this.#backgroundLayer.draw(canvas); }); } @@ -156,16 +155,26 @@ export class Renderer { const canvas = this.#getCanvas("scene"); if (!canvas) return; - canvas.withGlyphSpace({ x: 0, y: 0 }, () => { - this.#sceneLayer.draw(canvas); - }); + const ctx: RenderContext = { + canvas, + markers: this.#markerLayer, + }; + + this.#markerLayer.begin(); + try { + canvas.withSceneSpace({ x: 0, y: 0 }, () => { + this.#sceneLayer.draw(ctx); + }); + } finally { + this.#markerLayer.commit(); + } } #renderOverlay(): void { const canvas = this.#getCanvas("overlay"); if (!canvas) return; - canvas.withGlyphSpace({ x: 0, y: 0 }, () => { + canvas.withSceneSpace({ x: 0, y: 0 }, () => { this.#overlayLayer.draw(canvas); }); } diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Handles.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Handles.ts index 0b126f65..4d610814 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Handles.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Handles.ts @@ -1,10 +1,8 @@ -import type { Canvas } from "@/lib/editor/rendering/Canvas"; -import type { CameraTransform } from "@/lib/editor/managers/Camera"; import type { GlyphInstance } from "@/lib/model/Glyph"; import type { Hover } from "@/lib/editor/Hover"; import type { Selection } from "@/lib/editor/Selection"; import type { GlyphNode } from "@/types/node"; -import { MarkerLayer } from "@/lib/graphics/backends/MarkerLayer"; +import type { RenderContext } from "@/types/rendering"; import { HandleItems } from "./handles/HandleItems"; import { MarkerHandleRenderer } from "./handles/MarkerHandleRenderer"; import { CanvasHandleRenderer } from "./handles/CanvasHandleRenderer"; @@ -20,16 +18,8 @@ export class Handles { readonly #markers = new MarkerHandleRenderer(); readonly #canvas = new CanvasHandleRenderer(); - #markerLayer: MarkerLayer | null = null; - - setMarkerLayer(layer: MarkerLayer | null): void { - this.#markerLayer = layer; - this.#markers.resetUpload(); - } - draw( - canvas: Canvas, - camera: CameraTransform, + ctx: RenderContext, node: GlyphNode, instance: GlyphInstance, selection: Selection, @@ -40,13 +30,8 @@ export class Handles { hover, }); - if (this.#markers.draw(this.#markerLayer, list, camera, node.position)) return; - - this.#canvas.draw(canvas, list.items); - } + if (this.#markers.draw(ctx.markers, list, ctx.canvas.camera, node.position)) return; - clear(): void { - this.#markerLayer?.clear(); - this.#markers.resetUpload(); + this.#canvas.draw(ctx.canvas, list.items); } } diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/handles/MarkerHandleRenderer.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/handles/MarkerHandleRenderer.ts index aab81d3a..d4863afc 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/handles/MarkerHandleRenderer.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/handles/MarkerHandleRenderer.ts @@ -1,6 +1,6 @@ import type { Point2D } from "@shift/geo"; import type { CameraTransform } from "@/lib/editor/managers/Camera"; -import { MarkerLayer } from "@/lib/graphics/backends/MarkerLayer"; +import type { MarkerLayer } from "@/lib/graphics/backends/MarkerLayer"; import { MARKER_INSTANCE_FLOATS } from "../../markers/types"; import { STYLES, type CachedInstanceStyle } from "../../markers/handleStyles"; import type { HandleDisplayList } from "./HandleItems"; @@ -11,10 +11,11 @@ const EMPTY_PACKED_INSTANCES = new Float32Array(0); export class MarkerHandleRenderer { #packedInstances: Float32Array | null = null; #packedCapacity = 0; + #uploadedLayer: MarkerLayer | null = null; #uploadedList: HandleDisplayList | null = null; #uploadedInstanceCount = 0; - resetUpload(): void { + #resetUpload(): void { this.#uploadedList = null; this.#uploadedInstanceCount = 0; } @@ -28,6 +29,11 @@ export class MarkerHandleRenderer { if (!layer) return false; if (!layer.isAvailable()) return false; + if (layer !== this.#uploadedLayer) { + this.#uploadedLayer = layer; + this.#resetUpload(); + } + if (list !== this.#uploadedList) { this.#uploadedInstanceCount = this.#pack(list); if ( diff --git a/apps/desktop/src/renderer/src/lib/graphics/backends/MarkerLayer.ts b/apps/desktop/src/renderer/src/lib/graphics/backends/MarkerLayer.ts index 8c2e00b9..bc835a9f 100644 --- a/apps/desktop/src/renderer/src/lib/graphics/backends/MarkerLayer.ts +++ b/apps/desktop/src/renderer/src/lib/graphics/backends/MarkerLayer.ts @@ -32,6 +32,7 @@ export class MarkerLayer { #drawCommand: REGL.DrawCommand | null = null; #available = false; #instanceCapacity = 0; + #frameDrew = false; #centre: [number, number] = [0, 0]; #drawOffset: [number, number] = [0, 0]; #drawProps: MarkerDrawProps = { @@ -68,6 +69,18 @@ export class MarkerLayer { return this.#available; } + /** Starts a marker frame and forgets whether any marker content has drawn. */ + begin(): void { + this.#frameDrew = false; + } + + /** Clears the marker surface when the frame produced no marker draw. */ + commit(): void { + if (this.#frameDrew) return; + + this.clear(); + } + clear(): void { if (!this.#regl || !this.#available) return; this.#regl.clear(CLEAR_OPTIONS); @@ -122,6 +135,8 @@ export class MarkerLayer { if (!this.#regl || !this.#instanceBuffer || !this.#drawCommand || !this.#available) return false; + this.#frameDrew = true; + if (instanceCount === 0) { this.clear(); return true; diff --git a/apps/desktop/src/renderer/src/lib/nodes/GlyphNodeDefinition.ts b/apps/desktop/src/renderer/src/lib/nodes/GlyphNodeDefinition.ts new file mode 100644 index 00000000..9a46c4be --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/nodes/GlyphNodeDefinition.ts @@ -0,0 +1,186 @@ +import type { Rect2D } from "@shift/geo"; +import type { SegmentId } from "@shift/glyph-state"; +import type { NodePoint } from "@/types/coordinates"; +import { SCREEN_HIT_RADIUS } from "@/lib/editor/rendering/constants"; +import { OutlineRenderer } from "@/lib/editor/rendering/Outline"; +import { + Anchors, + ControlLines, + DebugOverlays, + Handles, + Segments, +} from "@/lib/editor/rendering/overlays"; +import type { GlyphInstance } from "@/lib/model/Glyph"; +import { NodeDefinition } from "@/lib/nodes/NodeDefinition"; +import type { GlyphNode } from "@/types/node"; +import type { RenderContext, RenderPass } from "@/types/rendering"; +import type { PointerTarget } from "@/types/target"; + +export class GlyphNodeDefinition extends NodeDefinition { + readonly kind: GlyphNode["kind"] = "glyph"; + + readonly #outline = new OutlineRenderer(); + readonly #debugOverlays = new DebugOverlays(); + readonly #controlLines = new ControlLines(); + readonly #anchors = new Anchors(); + readonly #segments = new Segments(); + readonly #handles = new Handles(); + + bounds(_node: GlyphNode): Rect2D | null { + return null; + } + + hit(node: GlyphNode, point: NodePoint): PointerTarget | null { + const instance = this.editor.font.instance(node.glyphId, this.editor.designLocationCell); + if (!instance) return null; + + const hit = instance.geometry.hitAt(point, this.editor.hitRadius); + if (!hit) return null; + + switch (hit.kind) { + case "segment": { + const segment = instance.geometry.segment(hit.id); + if (!segment) return null; + + return { + ...hit, + nodeId: node.id, + glyphId: node.glyphId, + point, + segmentId: hit.id, + pointIds: segment.pointIds, + }; + } + + case "point": + return { + ...hit, + nodeId: node.id, + glyphId: node.glyphId, + point, + pointId: hit.id, + }; + + case "anchor": + return { + ...hit, + nodeId: node.id, + glyphId: node.glyphId, + point, + anchorId: hit.id, + }; + } + } + + draw(node: GlyphNode, ctx: RenderContext, pass: RenderPass): void { + switch (pass) { + case "content": + this.#drawContent(node, ctx); + return; + + case "controls": + this.#drawControls(node, ctx); + return; + + case "background": + case "overlay": + return; + } + } + + #instance(node: GlyphNode): GlyphInstance | null { + return this.editor.font.instance(node.glyphId, this.editor.designLocationCell); + } + + #drawContent(node: GlyphNode, ctx: RenderContext): void { + const instance = this.#instance(node); + if (!instance) return; + + instance.render.trackShape(); + + this.#outline.draw(ctx.canvas, instance.render.outline, { + fill: null, + stroke: { + color: ctx.canvas.theme.glyph.stroke, + widthPx: ctx.canvas.theme.glyph.widthPx, + }, + }); + this.#drawDebugOverlays(node, ctx, instance); + } + + #drawControls(node: GlyphNode, ctx: RenderContext): void { + const instance = this.#instance(node); + if (!instance) return; + + const renderModel = instance.render; + + this.#segments.draw( + ctx.canvas, + instance.geometry, + this.#selectedSegmentIds(node), + this.#hoveredSegmentId(node), + ); + this.#drawControlLines(node, ctx, renderModel.contours); + this.#handles.draw(ctx, node, instance, this.editor.selection, this.editor.hover); + this.#anchors.draw(ctx.canvas, renderModel.anchors, { + selection: this.editor.selection, + hover: this.editor.hover, + }); + } + + #drawDebugOverlays(node: GlyphNode, ctx: RenderContext, instance: GlyphInstance): void { + this.#debugOverlays.draw( + ctx.canvas, + instance.geometry, + this.editor.debugOverlays, + this.#hoveredSegmentId(node), + ctx.canvas.pxToUpm(SCREEN_HIT_RADIUS), + ); + } + + #drawControlLines( + node: GlyphNode, + ctx: RenderContext, + contours: GlyphInstance["render"]["contours"], + ): void { + const sceneBounds = this.editor.camera.visibleSceneBounds(64); + const origin = node.position; + + this.#controlLines.draw(ctx.canvas, contours, (from, to) => { + const minX = Math.min(from.x, to.x) + origin.x; + const maxX = Math.max(from.x, to.x) + origin.x; + const minY = Math.min(from.y, to.y) + origin.y; + const maxY = Math.max(from.y, to.y) + origin.y; + return !( + maxX < sceneBounds.minX || + minX > sceneBounds.maxX || + maxY < sceneBounds.minY || + minY > sceneBounds.maxY + ); + }); + } + + #selectedSegmentIds(node: GlyphNode): readonly SegmentId[] { + const segmentIds: SegmentId[] = []; + + for (const object of this.editor.objects(this.editor.selection.ids)) { + if (object.kind !== "segment") continue; + if (object.node.id !== node.id) continue; + + segmentIds.push(object.segmentId); + } + + return segmentIds; + } + + #hoveredSegmentId(node: GlyphNode): SegmentId | null { + const id = this.editor.hover.id; + if (!id) return null; + + const object = this.editor.object(id); + if (object?.kind !== "segment") return null; + if (object.node.id !== node.id) return null; + + return object.segmentId; + } +} diff --git a/apps/desktop/src/renderer/src/lib/nodes/NodeDefinition.ts b/apps/desktop/src/renderer/src/lib/nodes/NodeDefinition.ts new file mode 100644 index 00000000..0b9f68e4 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/nodes/NodeDefinition.ts @@ -0,0 +1,57 @@ +import type { Rect2D } from "@shift/geo"; +import type { Editor } from "@/lib/editor/Editor"; +import type { NodePoint } from "@/types/coordinates"; +import type { ShiftNode } from "@/types/node"; +import type { PointerTarget } from "@/types/target"; +import type { RenderContext, RenderPass } from "@/types/rendering"; + +/** + * Defines behavior shared by every scene node of one kind. + * + * @remarks + * A definition is created once per editor and registered by `kind`. It owns + * kind-level behavior such as hit testing, bounds, and drawing; selected IDs + * still resolve through `ShiftObject` references. + */ +export abstract class NodeDefinition { + /** + * Creates behavior bound to one editor runtime. + * + * @param editor - editor session that provides font, scene, selection, hover, and camera context. + */ + constructor(protected readonly editor: Editor) {} + + /** Identifies the node kind this definition handles. */ + abstract readonly kind: N["kind"]; + + /** + * Returns scene-space bounds for a node. + * + * @param node - scene node handled by this definition. + * @returns null when this node has no bounds for selection or hit expansion. + */ + abstract bounds(node: N): Rect2D | null; + + /** + * Hit-tests a node-local pointer position. + * + * @param node - scene node handled by this definition. + * @param point - pointer position already converted into the node's local coordinate space. + * @returns the top target for this node, or null when the node was not hit. + */ + abstract hit(node: N, point: NodePoint): PointerTarget | null; + + /** + * Paints a node for one render pass. + * + * @param _node - scene node handled by this definition. + * @param _ctx - renderer-owned resources for the current frame. + * @param _pass - phase requested by the render layer. + */ + draw(_node: N, _ctx: RenderContext, _pass: RenderPass): void {} +} + +/** Constructs a node definition bound to one editor runtime. */ +export interface NodeDefinitionConstructor { + new (editor: Editor): NodeDefinition; +} diff --git a/apps/desktop/src/renderer/src/lib/objects/NodeObject.ts b/apps/desktop/src/renderer/src/lib/objects/NodeObject.ts index c31c5e38..e3682692 100644 --- a/apps/desktop/src/renderer/src/lib/objects/NodeObject.ts +++ b/apps/desktop/src/renderer/src/lib/objects/NodeObject.ts @@ -1,5 +1,6 @@ import type { Rect2D } from "@shift/geo"; import type { NodeId } from "@shift/types"; +import type { NodeDefinition } from "@/lib/nodes/NodeDefinition"; import type { ShiftObjectOf } from "@/types"; import type { ShiftNode } from "@/types/node"; @@ -7,30 +8,33 @@ import type { ShiftNode } from "@/types/node"; * Resolved scene node in the current editor scene. * * @remarks - * Node-specific bounds will eventually delegate to the node kind's object - * definition. Until those definitions exist, bare node bounds are absent. + * Node-specific bounds delegate to the node kind's definition when that + * behavior is registered for the current editor session. */ export class NodeObject implements ShiftObjectOf<"node"> { readonly kind = "node"; readonly id: NodeId; readonly node: ShiftNode; + readonly #definition: NodeDefinition | null; /** * Creates a scene node object. * * @param node - Placed scene node to expose as an object. + * @param definition - Registered behavior for this node kind. */ - constructor(node: ShiftNode) { + constructor(node: ShiftNode, definition: NodeDefinition | null) { this.id = node.id; this.node = node; + this.#definition = definition; } /** * Returns scene-space bounds for this node. * - * @returns null until node-kind bounds definitions are available. + * @returns null when this node kind has no registered bounds behavior. */ bounds(): Rect2D | null { - return null; + return this.#definition?.bounds(this.node) ?? null; } } diff --git a/apps/desktop/src/renderer/src/types/node.ts b/apps/desktop/src/renderer/src/types/node.ts index 5390b9eb..0bf6e2c0 100644 --- a/apps/desktop/src/renderer/src/types/node.ts +++ b/apps/desktop/src/renderer/src/types/node.ts @@ -49,3 +49,6 @@ export interface GlyphNode extends Node { /** Represents every scene node kind known to this build. */ export type ShiftNode = GlyphNode; + +/** Identifies a registered scene node behavior kind. */ +export type NodeKind = ShiftNode["kind"]; diff --git a/apps/desktop/src/renderer/src/types/rendering.ts b/apps/desktop/src/renderer/src/types/rendering.ts new file mode 100644 index 00000000..794343df --- /dev/null +++ b/apps/desktop/src/renderer/src/types/rendering.ts @@ -0,0 +1,27 @@ +import type { MarkerLayer } from "@/lib/graphics/backends/MarkerLayer"; +import type { Canvas } from "@/lib/editor/rendering/Canvas"; + +/** + * Names the editor paint phase requested from node definitions. + * + * @remarks + * Render layers own pass ordering. Node definitions choose which phases they + * paint in and ignore the rest. + */ +export type RenderPass = "background" | "content" | "controls" | "overlay"; + +/** + * Carries renderer-owned resources through one draw frame. + * + * @remarks + * Render layers own z-order and pass sequencing. Node definitions and drawing + * helpers receive this context so transient renderer resources, such as the + * marker backend, do not become retained state outside the renderer. + */ +export interface RenderContext { + /** Canvas configured for the layer currently being drawn. */ + readonly canvas: Canvas; + + /** Marker backend owned by the renderer for the current frame. */ + readonly markers: MarkerLayer; +}