Skip to content

Promote Segment/sidebearings to domain classes; align Editor accessors#42

Merged
kostyafarber merged 6 commits into
mainfrom
firmclaw/domain-model-sweep
Apr 22, 2026
Merged

Promote Segment/sidebearings to domain classes; align Editor accessors#42
kostyafarber merged 6 commits into
mainfrom
firmclaw/domain-model-sweep

Conversation

@kostyafarber
Copy link
Copy Markdown
Collaborator

Summary

Four-commit sweep promoting free functions and static namespaces into the reactive domain classes (Glyph, Contour, Segment), and aligning Editor's accessor convention to match. Also introduces three new oxlint rules that catch the old patterns at authoring time.

Commits (read in order)

  1. Segment domain class + selection bounds signal (9708a78)

    • New Segment class in lib/model/Segment.ts replaces duplicate Segment/Segments static namespaces in lib/geo/.
    • Contour.segments() and Glyph.segments() generators.
    • Contour.selectionBounds(ids) + Selection.\$bounds reactive computed — fixes the stale X/Y in TransformSection/ScaleSection during drag.
    • Deletes lib/transform/SelectionBounds.ts and editor.getSelectionBounds() / getSelectionCenter().
    • New lint rules: `prefer-instance-method-on-glyph`, `no-raw-segment-parse`.
  2. Curve method promotion onto Segment (c7073c6)

    • segment.pointAt(t), closestPoint(pos), splitAt(t), sample(n), length delegate to Curve.* on this.toCurve().
    • asLine() / asQuad() / asCubic() narrow the raw discriminated data without a cast — replaces (segment.raw as CubicSegment) in BendCurve and SplitSegmentCommand.
    • Curve.ts stays as the pure-geometry layer for composite contours and other shapeless callers.
  3. glyph.sidebearings reactive computed (5a54de0)

    • Glyph.sidebearings computed + `$sidebearings` / `$xAdvance` signal getters. Sidebar LSB/RSB now live-update during drag.
    • GlyphSection splits outer/inner so the reactive subscription is re-established on glyph load.
    • Deletes lib/editor/sidebearings.ts free functions.
    • Rename isLikelyNonSpacingGlyphRefisNonSpacingGlyph.
    • displayAdvance(advance, glyphName, unicode) moved to unicode.ts (shared with layout.ts).
  4. Editor accessor alignment (545c95a)

    • Editor's `foo` / `getFoo()` pattern was inverted from Glyph/Contour. Now matches:
      • `foo` getter returns value (auto-unwraps)
      • `$foo` getter returns `Signal` for React `useSignalState`
    • Converted: previewMode, handlesVisible, gpuHandlesEnabled, isHoveringNode, snapIndicator, currentModifiers, debugOverlays, cursor, zoom, drawOffset; plus one-shot hoveredBoundingBoxHandle / hoveredSegmentId.
    • activeTool/activeToolState left on old pattern pending upcoming tool refactor (silenced with oxlint-disable).
    • New lint rule: `no-get-signal-value-method` — flags `getFoo() { return this.#signal.value; }`.

Net change

Before After
Files -5 deleted (Segment geo duplicates, SelectionBounds, sidebearings)
Live-update bugs fixed 2 0 (TransformSection, ScaleSection, GlyphSection all subscribe to correct signals)
Lint rules 10 13

Test plan

  • `pnpm check` passes (typecheck + lint + 534 tests — already green)
  • In dev app, drag a point while selected → TransformSection X/Y updates live
  • Drag a point → ScaleSection W/H updates live
  • Drag a point → GlyphSection LSB/RSB updates live
  • Change xAdvance in sidebar → LSB/RSB recalculate
  • Select → Alt+drag Bezier control (BendCurve) still works — uses `segment.asCubic()`
  • Pen tool → click on a segment to split it (SplitSegmentCommand) — uses new splitAt/pointAt instance methods
  • Editor.ts: hovering, cursor, preview mode toggles, GPU handles all continue working

🤖 Generated with Claude Code

Segment becomes a first-class reactive-friendly class in lib/model/Segment.ts,
alongside Glyph and Contour. Adds Contour.segments() and Glyph.segments()
generators, and a Selection.#bounds computed signal that tracks glyph
contours + selection ids — fixes the stale TransformSection/ScaleSection X/Y
during drag.

- New Segment class wraps discriminated SegmentType data and exposes
  .id, .bounds, .pointIds, .anchor1/2, .toCurve(), .hitTest(),
  plus static parse() and hitTestMultiple()
- Contour.selectionBounds(ids) and Selection.$bounds replace the free
  getSegmentAwareBounds / editor.getSelectionBounds / editor.getSelectionCenter
- Deleted lib/geo/Segment.ts + lib/geo/Segments.ts duplicate static namespaces
- Deleted lib/transform/SelectionBounds.ts (logic lives on Selection now)
- SegmentHit.segment, Editor#$segmentIndex, getSegmentAt, splitSegment,
  upgradeLineToCubic, indicators/Segments renderer all take Segment instances
- SnapManager/DebugOverlays/DebugPanel/Editor/bench swap Glyphs.* statics
  for Glyph instance methods (glyph.allPoints, glyph.point(id), etc.)

Lint:
- Update no-raw-contour-access message to point at domain methods
- Add prefer-instance-method-on-glyph (catches static Glyphs.*/Contours.*
  called on class instances)
- Add no-raw-segment-parse (use contour.segments()/glyph.segments()
  instead of Segment.parse / parseContourSegments in app code)
- Segment gains instance methods: pointAt, closestPoint, splitAt, sample, length
  All delegate to Curve.* on this.toCurve(). Callers drop manual toCurve() +
  Curve.x(curve, ...) pairs.
- asLine/asQuad/asCubic narrow the raw discriminated data without a cast.
  Replaces (segment.raw as CubicSegment) in BendCurve and SplitSegmentCommand.
- BendCurve and SplitSegmentCommand migrated to the new API.
- Curve.ts stays as the pure-geometry layer for composite contours and other
  shapeless callers; Segment layers id-aware methods on top.
- Glyph gains sidebearings (computed) + $sidebearings / $xAdvance signal
  getters. Sidebar LSB/RSB now live-update during drag — same staleness bug
  that TransformSection had.
- GlyphSection splits into an outer (subscribes to glyph identity) and
  inner view (subscribes to glyph.$sidebearings and $xAdvance) so the
  reactive subscription is established on every glyph load.
- Editor.setLeft/RightSidebearing read glyph.sidebearings directly.
- Delete lib/editor/sidebearings.ts free functions + test.
- displayAdvance(advance, glyphName, unicode) moved to unicode.ts as the
  shared non-spacing fallback helper; replaces editor.getVisualGlyphAdvance
  (pure rendering concern, doesn't belong on the Glyph class).
- Rename isLikelyNonSpacingGlyphRef → isNonSpacingGlyph (stale "Ref"
  suffix from when it was tied to composite glyph refs).
Editor had the accessor convention inverted from Glyph/Contour:
 - `editor.previewMode` returned Signal<boolean>, `isPreviewMode()` returned the value
 - `editor.getIsHoveringNode()` / `getSnapIndicator()` / etc. were methods with no
   corresponding signal accessor at all

Now matches the domain-class convention:
 - `foo` getter returns the value (auto-unwraps)
 - `$foo` getter returns the Signal<T> (for React useSignalState)

Converted pairs:
 - previewMode / $previewMode        (was isPreviewMode() + get previewMode: Signal)
 - handlesVisible / $handlesVisible  (was isHandlesVisible() + get handlesVisible: Signal)
 - gpuHandlesEnabled / $gpuHandlesEnabled
 - isHoveringNode / $isHoveringNode  (was getIsHoveringNode())
 - snapIndicator / $snapIndicator    (was getSnapIndicator())
 - currentModifiers / $currentModifiers
 - debugOverlays / $debugOverlays
 - cursor / $cursor                  (was getCursor())
 - zoom / $zoom
 - drawOffset / $drawOffset

One-shot computes (no signal):
 - hoveredBoundingBoxHandle, hoveredSegmentId

activeTool / activeToolState left on old pattern pending tool refactor,
silenced with oxlint-disable comments.

Private signal fields renamed from `$foo` → `#foo` to match Glyph convention.

New lint rule: shift/no-get-signal-value-method — flags `getFoo()` / `isFoo()`
methods that just return `this.#signal.value`. Prevents regression.
…ription

The sidebar's \`useSignalState(selection.\$bounds)\` / \`glyph.\$sidebearings\`
subscription was forcing the expensive bounds computeds to run inside the
drag hot path every frame. At 50K points all-selected this blew the p95
ceiling on translate/nudge/undo tests by 4-10x.

Pixi's pattern: per-entity bounds caches are invalidated lazily, the
aggregate (\`stage.getBounds()\`) is called once per render frame, and each
child returns its cached value unless individually dirty. The render loop
pulls; nothing subscribes to bounds reactively.

Translated to our sidebar:
 - New \`useSignalTrigger(signal)\` hook: subscribes purely for re-render
   side effects, returns nothing. Lets a component re-render on a coarse
   "something changed" signal without forcing expensive derivations to
   run inside the subscribe callback.
 - Expose \`glyph.\$contours\` as the coarse signal. Fires once per
   \`#patchPositions\` batch (already how \`#contours\` behaves).
 - \`TransformSection\`, \`ScaleSection\`, \`GlyphSection\` subscribe to
   \`glyph.\$contours\` for re-render, then pull \`selection.bounds\` /
   \`glyph.sidebearings\` on demand at render time.

During a tight-loop drag (perf tests): setPositions → fires #contours →
useSignalTrigger effect bumps a useSyncExternalStore version → React
queues a render for next rAF → no compute happens in the loop. The
bounds computed only runs when React actually renders, at most once per
animation frame.

Result: all 8 perf tests pass, matching main's 53s baseline.

Note: \`selection.\$bounds\`, \`glyph.\$sidebearings\`, \`glyph.\$xAdvance\` all
remain as public APIs for non-sidebar callers or future use once
incremental signals land (see \`incremental-signals.md\` ticket).
…foo signals

Previous commit fixed perf by subscribing to \`glyph.\$contours\` and pulling
\`selection.bounds\` / \`glyph.sidebearings\` on demand. That worked but was
dishonest — the \`useSignalTrigger(glyph.\$contours)\` call had a hidden
side-effect making unrelated reads reactive. A new reader couldn't tell
what subscription was keeping the bounds live.

Clean up:

- Remove public \`\$\`-accessors for expensive derived signals:
    * \`selection.\$bounds\` (footgun: expensive ComputedSignal looked cheap)
    * \`glyph.\$sidebearings\` (same)
  These stay as plain getters \`selection.bounds\` / \`glyph.sidebearings\`
  for pull access. Internal ComputedSignals remain (caching) but aren't
  exposed for subscription.
- Add purpose-specific React hooks that bundle "subscribe to raw inputs
  + pull derived value" into one honest unit:
    * \`useSelectionBounds()\`
    * \`useGlyphSidebearings()\`
    * \`useGlyphXAdvance()\`
- Migrate sidebar components. Each line now expresses what it genuinely
  depends on; no hidden subscription side-effects.
- Make \`glyph.#sidebearings\` computed point-based (matches the bounds
  treatment) so neither warms the bezier \`#bbox\` chain.
- \`Editor.setLeft/RightSidebearing\` still need bezier-accurate values for
  correct delta computation — pull \`glyph.bbox\` directly (one-shot on
  command execution, fine to pay the cost).

Convention in \`lib/reactive/docs/DOCS.md\`:
  \`\$foo\` accessor = raw state, safe to subscribe via useSignalState/Trigger.
  \`.foo\` getter = derived value, pull at read time.
  For React live display of derived values, write a purpose-specific hook.

Perf: 51.6s — matches main baseline.
@kostyafarber kostyafarber merged commit 988777b into main Apr 22, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant