Skip to content

Releases: LubrieDev/lmath

1.3.3

Choose a tag to compare

@LubrieDev LubrieDev released this 03 Aug 02:15
0c07e46

1.3.3

Why 1.3.2 was superseded

1.3.2 did not pass Obsidian's automated review. Everything else in that review passed: the
main.js and styles.css assets carried verified GitHub attestations, no suspicious network
patterns were found, no vulnerable dependencies were detected, and the build check reproduced
the released main.js byte for byte from the repository, which is the strongest thing the
report can say about a plugin. The only failure was in the source-code check: eight instances of
obsidianmd/no-static-styles-assignment, all of them in the new block's chrome.

A release marked Failed is not one anyone should install, so 1.3.2 is superseded by this
one
. This release is 1.3.2 with that check satisfied and nothing else changed: the plugin
behaves exactly as the 1.3.2 release notes describe, and that document remains the reference
for what obs-trig does.

The fix

The eight findings were one defect repeated, not eight different problems: styling that never
changes, written as inline strings in the code instead of as rules in the stylesheet
. The rule
asks for CSS classes, setCssStyles or setCssProps, and all eight cases could simply be
replaced with CSS classes. What stayed in the host is what genuinely depends on values computed
at runtime: the chip's side, the box's width and the handle's position. That is why the review
never flagged them.

  • The slider's focus ring left the TypeScript entirely. A focus listener and a blur
    listener that painted an outline became a single :focus-visible rule in styles.css. That
    also fixes an accessibility detail: the ring used to appear on mouse clicks too, which is
    precisely when nobody needs it. :focus-visible lets the browser make that distinction. The
    inline outline: none had to disappear with those listeners, otherwise it would have overridden
    the stylesheet rule and the focus ring would never have appeared.

  • The trig controls' two layouts became two CSS classes, toggled at the width threshold
    instead of swapping style strings over one another. The height of the bottom strip now reaches
    the stylesheet through setCssProps as a custom property, so ALTO_CONTROLES_TRIG remains the
    single source of truth for that measurement. Copying the value into the stylesheet would have
    created two sources of truth for the same layout, and that dimension is shared with the canvas.

  • The remaining four findings were static pieces: the reading band, the component row, the
    live value itself — the large number the whole panel revolves around — and one row of the ratio
    table. They are now ordinary CSS classes. The row that holds that value (θ on the left, the
    number on the right) was converted as well, even though the review did not flag it, because it
    was the same kind of rule one line away. Converting only half of a block is how a stylesheet
    becomes harder to maintain.

What did not change

No behavior, no syntax, no rendering and no settings changed. minAppVersion remains
1.13.0. If any block, including obs-trig, behaves or renders differently after updating,
that is a defect and should be reported.

Validation

  • Main suite: 486 passed, 0 failed.
  • Zoom suite: 12 passed, 0 failed.
  • Typecheck and build: clean.
  • Review audit: 0 findings, down from the eight this release exists to remove.
  • CSS audit: clean.

What the tests do not cover. The block host still has no automated DOM tests, and this
release only moved presentation code, which is exactly the part no automated suite can see. Two
things were therefore verified manually: that the slider shows its focus ring when reached with
Tab but not when clicked, and that the controls transition correctly across the 520 px
breakpoint in both directions.

1.3.2

Choose a tag to compare

@LubrieDev LubrieDev released this 03 Aug 01:30
d1e5874

Version 1.3.2

This release adds one new block, obs-trig, and two settings that go with it. It is the first
LMath block that does not plot a curve: it draws the unit circle, and you drive an angle around it.

It also brings an angle unit selector — degrees, radians or gradians — as a global plugin
setting and as a chip on the block.

Nothing else changes. The four existing blocks — obs-graph, obs-system, obs-derivate,
obs-integral — keep the same syntax and draw the same curves, and minAppVersion stays at
1.13.0.


The new block: obs-trig

An empty block already renders a working figure at 30°: in this block the unit circle is the
content, so there is nothing missing to report.

```obs-trig
θ = 30°
```

Writing a block

One line, one angle. The = only assigns a name, and the name is optional — it is not an
equation, so 30° on its own is a complete block and gets called θ.

you write you get
(empty) 30°
30° one angle, named θ by default
θ = 30° the same angle, named θ explicitly
α = 30°δ = 330° four angles at once
θ = 750° two turns and 30°: the point sits at 30° and the turn count says so
θ = -45° negative angles are ordinary input
θ = 30 30 radians, not 30 degrees
θ = \frac{\pi}{6}, pi/6, 2\pi π is written however you write it anywhere else

The two unit rules, which are not the same rule. The angle a block declares is read in
radians when it is a bare number: θ = 30 is 30 radians and lands at 1718.9°, not at 30°.
Degrees need the °. But inside a trigonometric function the plugin keeps its usual
convention, which is the opposite one: a literal argument is read in degrees, so sin(30) is
0.5 — sine of 30 degrees — here exactly as in obs-graph. Written as the angle of a block,
θ = sin(30) is therefore 0.5 radians. Two rules, two places; the ° is what removes all
doubt from the first.

A line that is not a readable angle is reported in the panel, just under the card — not over
the plane, where it would cover the controls. At most three are listed, and a +N counts the
rest. If no line is readable the block still draws: it falls back to 30° and reports what it
could not read.

Naming a ratio turns its trace on

If the expression is exactly a call to sin, cos or tan on a constant angle, the block
opens with that component already drawn, solid and with its construction. Writing the name of a
ratio is already saying which one you want to look at; making you press the toggle afterwards is
asking you to repeat yourself.

```obs-trig
sin(30)
```

It chooses a trace, it does not reinterpret the source. The angle is still the value the
expression evaluates to, by the same rule as everywhere else — sin(30) is 0.5, so the block
draws 0.5 radians and lights the sine of that angle. Nothing about the reading changes.

"Exactly" is meant literally, and it is checked on the normalized expression, which is the one
that actually gets evaluated:

you write opens with
sin(30), \sin{30}, \sin 30, cos(45°), \tan{\frac{\pi}{4}} that component
2sin(30), -sin(30), sin(30)*2, sin(30)+cos(30) nothing: the call is not the whole expression
asin(0.5), arcsin(0.5), sinh(1), cot(30), sec(30) nothing: no trace on the figure
sin(x) nothing — and it is not an angle either, so it is reported
30°, \frac{\pi}{6}, 750° nothing, as before

Only the angle that opens active is consulted, because the components belong to the active
angle. And it is only a seed: the moment you touch a toggle the selection is yours, and moving to
another angle with Tab does not seed it again — a choice that undid itself as you navigated would
be the same mistake as rewriting the note when you drag.

The figure

The framing is fixed — no camera, no zoom, no pan. The wheel is never captured, so the note
scrolls normally when the mouse is over the block. On touch the plane does keep the gesture, as
every LMath plane does: a swipe that starts on the circle drives the angle instead of scrolling the
note, so scroll from the panel or from the margin.

  • Two grids. A faint cartesian 1:1 grid and dotted radial spokes every 15°. Each measures what
    the other cannot: the cartesian grid measures the vertical and horizontal lengths that sine and
    cosine are, so "this leg is a half" can be counted in quarter-unit cells — half-unit ones once
    the circle gets small enough that a quarter would stop being legible — while the spokes
    measure the angle and anchor the labels around the rim. The four multiples of 90° carry no spoke —
    the axes are already there, and a dotted line over an axis only blurs it — so 20 are drawn.
  • The 24 notable angles are marked on the circumference, the sixteen classics — the multiples
    of 30° and 45° — with a fatter dot than the rest. Those sixteen are also labelled outside the
    rim in both units at once, degrees over the fraction of π. The labelling adapts to the size
    of the plane: two lines, then one line in the active unit, then the four axes only, then none.
    The label that falls under the active angle stays quiet, because the coordinates of P need that
    gap — and it is matched by coterminal, so θ = 750° silences the label at 30°.
  • Per angle, its terminal side from the origin and its point on the circumference. The
    active angle is drawn last, so it stays on top where two cross, and it is heavier and carries
    a halo. It also takes the figure's own line colour; the angles that are not active are the ones
    that carry a colour each, from the same palette the other blocks use for several curves.
  • The exact coordinates of P next to the point: (√3/2, 1/2) where other tools print
    (0.866, 0.5). Only for the active angle, and only when it has earned an exact value (see
    below); otherwise nothing is written.

Sine, cosine and tangent

The three are always on the plane — for the active angle, which is the one the whole panel
talks about — dotted and each in its own colour: sine purple, cosine blue, tangent green. The
panel's three toggles promote them from dotted to solid and add their construction: the sine's
guide to the axis, and for the tangent the auxiliary line x = 1 and the point S where it meets the
terminal side. They are independent, so you can have none, one, two or all three, and a block opens
with none of them promoted unless it names one (see above).

The three tones are the plugin's own blue, green and purple — the same ones the other blocks give
to several curves, not a new family — and the suite checks that the three stay distinct in both
themes. The names sin, cos and tan in the panel carry the colour of their component, so the
table reads as the plane's legend.

The tangent is joined to the terminal side. Where the join starts depends on the sign of the
cosine, because the tangent is built on the terminal side's line, not on its ray: with cos θ > 0
it runs from P outward, and with cos θ < 0 the line meets x = 1 on the opposite side, so it runs
from the origin.

At 90° and 270° the drawing shows why the tangent does not exist. There the terminal side is
the Y axis, parallel to x = 1, so it never meets it: only that prolongation is drawn, running off
the plane. No segment over x = 1, which would be infinite, and no point S, which has nowhere to be.

Exact values

All 24 notable angles — the multiples of 15° — carry their six ratios in exact form, √3/2 and
(√6−√2)/4 included. On the four angles that land on an axis two of the six do not exist, and
those read "undefined" rather than a number.

They are written in plain unicode, on the plane and in the panel alike: the panel is rebuilt on
every frame of a drag, and putting six KaTeX formulas through that would be untenable — at this
size the unicode reads just as well. The only formula the block renders as maths is the fixed
x² + y² = 1 at the top of the panel.

The right to an exact value comes from the written text, not from the number. A block earns it
by naming the angle in degrees or in terms of π — 30°, \frac{\pi}{6}, 2\pi. 0.5236 never
claims to be sine 1/2, however close it passes to π/6, and neither does θ = 30, which is a
perfectly ordinary angle of 30 radians with no closed form.

An angle produced by the block's own controls earns the right too, because its provenance is
known: drag it, step it with the keyboard, move the slider or run the animation and that angle may
show exact values from then on. It will only actually show them when it lands on a multiple of 15°
— which the magnet, Page Up/Page Down and Home do exactly. A decimal typed by hand never
earns it.

The panel

Everything in it describes the active angle. Three bands:

  1. x² + y² = 1, fixed, with the live point below it: P(30°) = (√3/2, 1/2). The law that
    defines the figure stays put while the point that satisfies it moves. The angle is written in
    the unit the chip selects, so it reads P(π/6 rad) or P(33.33 gon) just the same.
  2. The reading. With exactly one component selected, that ratio alone and large, headed by its
    name in its own colour; with none or with several, the three ratios in a table. Each one shows
    its exact form with the decimal beside it, or just the decimal when there is no exact form.
  3. The controls: the three component toggles, the live value of θ, and the angle slider.

Below 520 px of block width the panel stops being a column: it floats over a square plane and
starts closed. The controls are the one part that does not go with it — they move to a fixed strip
a...

Read more

1.3.1

Choose a tag to compare

@LubrieDev LubrieDev released this 31 Jul 00:38
edbf3b2

Version 1.3.1

This release touches three areas: the explicit tracer's geometry budget, the algebra and
notation of the panels
, and the ⓘ information panel, which now describes polar curves,
parametric curves, integrals and derivatives on their own terms instead of with categories
borrowed from y = f(x).

The common problem behind all three is the same: results that were correct but not usable.
The tracer spent a correct budget in an order that made two passes disagree; the panel printed
values that were exactly right and unreadable (7.0710678118654755 for √50, 1.5708 for
π/2); and the ⓘ panel answered cartesian questions about objects that are not cartesian curves
— it told you where the integrand of ∫₀² 2x dx crossed the y axis, which is a fact about a line,
not about the integral the block is there to compute.

What a user notices: less flicker when releasing a drag on dense curves, expressions written the
way a person writes them, and an information panel that describes the object in front of them.
Two changes affect existing notes — one alters what a block draws, the other raises the minimum
Obsidian version. See Breaking changes.


What changed?

Graphics engine and tracer

Before. 1.2.9 capped how much geometry the explicit tracer may emit, so tan(e^x) could no
longer take Obsidian down. The ceiling was right; its distribution was not. It was a single
global bag of anchoPx × 2048 vertices, spent in sweep order, left to right.

Now. Each pixel column carries its own quota. The bar per column is the same 2048, so the
total ceiling does not rise — density in one place can no longer borrow from another.

Why the old scheme was visible. The point at which the bag ran dry depended on how many base
samples a pass had, and the interactive pass and the final pass do not have the same number. They
exhausted it at different values of x and truncated different branches, which is flicker arriving
exactly when you release the mouse. Measured over the canvas:

view pixels changed between gesture and final image
tan(x²) at ±300 14.2 %
tan(x²) at ±200 3.5 %
tan(e^x) default 9.9 %

(Those three are measurements of the old code, taken while the defect existed. They are not
reproducible from this build for the obvious reason, and are quoted as the reason the change was
made, not as evidence of its result — for that, see the pixel maps under Validation.)

In the last one the gesture pass drew more than the final one — detail appeared while dragging
and vanished on release, the opposite of what a final pass is for. Views where the budget never
bites, which is the whole ordinary repertoire, were unaffected then and are unaffected now.

A fairer split of the same ceiling also does less work, which is not obvious. The old bag
was genuinely being spent to the last vertex: in tan(e^x)'s default view, 768 px × 2048 is
1 572 864 and the tracer emitted 1 573 289. With a per-column quota only the dense columns
hit the bar and the sparse ones never spend what they do not need: 521 914 vertices, 67 %
fewer, for a nearly identical drawing. It also bites locally where a global bag noticed nothing:
tan(x²) at ±120 was nowhere near the old ceiling and still emits 9 % fewer vertices while
painting exactly the same pixels — that refinement was sub-pixel work inside crowded columns.

Two of the thirty-four bench cases draw differently, both in the pathological zone.
Everywhere else the pixel map is identical, hash for hash, for less work.

case pixels branches asymptotes
tan(x²) ±300 129 861 → 136 300 (+5 %) 11 914 → 10 770
tan(e^x) default 72 313 → 91 908 (+27 %) 23 740 → 18 228 23 734 → 6 385

The asymptote drop deserves stating plainly: agotado decides not only how much geometry is
emitted but also whether a pole is recorded as an asymptote and whether a continuous crossing is
joined instead of split, so stopping earlier in a column registers fewer. In that view they were
31 per pixel of width and are now 8 — still denser than the screen can separate, but whether
this is visible has not been verified
, and the asymptote overlay draws outside the saturated
band as well as inside it. This is the one open question in the release.

Algebra and symbolic simplification

Exact constants recovered from decimals

Before. Recovering an exact constant scanned a table of √k for k = 2…40. Inside the
table it worked; one step outside, the decimal went to the screen. √50, √72, √300, √20+√5
(which is √45) and every cube root that has ever existed were outside it.

Now. Arithmetic instead of a catalogue: square the number and see whether an integer comes
back. That covers all k rather than the first forty, extends to cube roots for free, and costs
one multiplication.

Rationalisation limited to fractions people write

Before. simplify turns every decimal into an exact fraction with a factory denominator
limit of 10 000. A four-digit denominator is the decimal expansion wearing a hat.

Now. Lowered to 64. Everything genuinely written by hand survives — 0.51/2,
2.55/2, 0.1251/8, 1.757/4, 0.06251/16 — and 0.5637 is left as typed.

you write before now
√20 √20 2√5
√50 7.0710678118654755 5√2
√20+√5 6.708203932499369 3√5
∛54 3.7797631496846193 3∛2
√(20x) √(20x) 2√(5x)
x^{0.5637} \sqrt[10000]{x^{5637}} x^{0.5637}
(x^{0.5637})² \sqrt[5000]{x^{5637}} x^{1.1274}
√2/2 1/√2 √2/2

That last row ran backwards: the reciprocal was returned unrationalised, so writing the
correct form produced the incorrect one and Simplify stopped being a no-op on its own output.

Pulling a square factor out of a radical is exact and does not move the domain (20x ≥ 0 and
5x ≥ 0 are the same condition), so no curve changes. √(2)^√2 correctly stays a decimal — it
is not a·ⁿ√b in any form, and the recogniser says so instead of guessing.

Radicals are drawn only when they read better than the power

Before. The emitter's rule was "a rational exponent becomes a radical", with no ceiling on
the index
; the rationalisation limit above was doing all the work. Writing the fraction
yourself still produced x^{5/64}\sqrt[64]{x^{5}} and x^{7/32}\sqrt[32]{x^{7}}.

Now. Two ceilings, applied to the radical that would actually be painted:

  • index ≤ 5 with a power inside;
  • index ≤ 8 for a pure root, which has no exponent to read on top — \sqrt[8]{x} is fine,
    \sqrt[8]{x^{7}} is worse than x^{7/8} and is exactly the trade being avoided.

The 5 rather than a rounder 4 is a deliberate trade-off: y^{2.5} = x ⇒ ⁵√(x²) is a case this
same release adds, and a ceiling of 4 silently removed it. Where the readability argument cannot
separate two options, not regressing an existing decision wins.

The purpose of a ceiling is stability: under the old rule an expression's appearance depended
on whether the rationaliser happened to find a fraction, which is an internal detail.

Irrational exponents are also left alone now. x^{π/2} used to be painted \sqrt{x^{π}}, which
is true and not what anyone writes: the radical is the canonical notation for a rational
exponent, where p/q means "q-th root of the p-th power", and the /2 in π/2 is ordinary
division, not an index. Same for φ/2, e/3 and τ/4. Consistency is preserved in the other
direction: x^{0.5φ} and x^{φ/2} still render identically.

Radicals finish the job

Before. Converting a rational power to a radical was the only rewrite. \sqrt[4]{x^{11}} is
correct and nobody writes it.

Now. The Euclidean division m = q·k + r pulls every perfect power out of the radicand, and
a negative rational exponent becomes the reciprocal of the radical.

you write before now
x^{3/2} \sqrt{x^{3}} x\sqrt{x}
x^{7/2} \sqrt{x^{7}} x^{3}\sqrt{x}
x^{11/4} \sqrt[4]{x^{11}} x^{2}\sqrt[4]{x^{3}}
x^{5/4} \sqrt[4]{x^{5}} x\sqrt[4]{x}
(2*x)^(5/2) 4\sqrt{2}x^{2}\sqrt{x} 4x^{2}\sqrt{2x}
(3*x)^(7/2) 27\sqrt{3}x^{3}\sqrt{x} 27x^{3}\sqrt{3x}
x^{-1/2} x^{\frac{-1}{2}} \frac{1}{\sqrt{x}}
x^{-5/2} x^{\frac{-5}{2}} \frac{1}{x^{2}\sqrt{x}}

† The two composite-base rows hold for that spelling only; written (2x)^{5/2} with braces the
panel shows 4\sqrt{2x^{5}}. The reason, and what it would take to close it, are at the end of
this section.

Written as a decimal the result is identical: x^{1.5} and x^{3/2} are the same function and
now look it. A negative integer exponent stays a power, because x^{-2} is already how that
is written.

Composite bases stay composite. simplify distributes a power over a product, turning
(2x)^{5/2} into 4·2^{1/2}·x^{5/2}, and the panel painted 4√2·x²√x — two loose radicals
where one is written by hand. It also contradicted the plugin's own convention, since √(20x)
is left as 2√(5x). Reaching 4x²√(2x) needed no new machinery, only the right order: the
Euclidean extraction moved from the emitted LaTeX to the tree, so splitting x^{5/2} into
x²·x^{1/2} as nodes leaves that x^{1/2} visible to a second pass, which merges radicals of
equal exponent and finds 2^{1/2} beside it. On the finished string that √x was text and
could not be touched.

Merging radicands is guarded, not free. √a·√b = √(ab) fails when both are negative
(√(−1)·√(−1) is NaN while √1 is 1). One radicand being provably non-negative is enough: then
`a...

Read more

1.3.0

Choose a tag to compare

@LubrieDev LubrieDev released this 26 Jul 19:32
6c4dffc

1.3.0

The block on a phone: the plane gets the whole width, the formula waits behind a button

Until now the block applied a desktop layout everywhere. The formula asks for half the
width and the plane takes two thirds of what is left, which on a 412 px phone leaves a
plane 214 px wide and 261 px tall — a graph taller than it is wide, with a panel beside
it whose lower half is empty. Nothing was broken; it was a proportion designed for a
window that a phone does not have.

This release splits that decision in two, because two different things were being
confused
: how much room there is, and how you are pointing. They now travel separately,
and that is what lets the phone gain a usable plane without changing anything in
landscape
, where a phone has ~700 px and the desktop layout already works.

  • A narrow block is now just the plane, with the formula in a panel on top of it.
    Below 520 px of container width, the formula panel leaves the flow and becomes a card
    floating over the plane, opened and closed by a new f(x) button next to the ⓘ. The
    plane goes from 214 × 261 to 321 × 264 on that same phone — 52 % more area, and
    landscape at last.

    The threshold is not a round number picked by feel. In the side-by-side layout the plane
    takes ⅔ of the block, so for it not to end up taller than wide you need ⅔·W ≥ 261, that
    is W ≥ 392; at 520 the plane never drops below 4:3, which is the least a graph needs to
    read as a graph. Being measured on the container and not on the device, a narrow side
    pane on the desktop gets the same treatment — it has exactly the same problem.

    The panel does not change parent when the threshold is crossed: it stays a sibling of
    the plane and only its box is rewritten. So rotating the phone is one style write —
    KaTeX is not re-rendered, and your zoom and panning survive the turn. Rotating with the
    formula open closes it, so coming back to portrait starts closed rather than with a panel
    nobody asked to open.

    The card takes 180 of the 264 px and rests 46 px above the bottom edge instead of
    sitting on it: the ⓘ and the button that closes the formula live down there, and a panel
    that covers its own close button is a trap. While it is open the f(x) turns into ✕,
    the same rule the ☰ menu got in 1.2.8, and the zoom column steps aside — at that height
    it would be underneath the card. That is the price of a large panel, and it is the right
    one: with the formula in front of you, you are not navigating the graph. Tapping the
    plane also closes it, but only a clean tap — under 8 px of travel and half a second —
    because a drag to move the view ends up emitting a click just like a tap, and closing the
    panel every time you moved the plane would make it unusable.

  • One finger moves the plane on both axes; two fingers zoom. Until now the only zoom on
    a phone was the ± buttons, which are precisely the ones that step aside when the formula
    is open. Panning was also fragile: a single stored position meant the second finger's
    pointerdown overwrote the first one's, and the view jumped from one finger to the
    other.

    Pointers are now tracked per id. With one, the arithmetic is the same one as always,
    so the desktop drag is unchanged. With two, the midpoint drags the view and the
    separation scales it, anchored on that midpoint: pulling the fingers apart zooms in,
    bringing them together zooms out, and the point of the world under your fingers stays
    where it is. Lifting one finger no longer jumps, because the one that remains already has
    a known position. pointercancel is finally handled — the system can take a finger away
    at any moment (an incoming call, a gesture of its own), and that finger used to stay
    "down" forever.

    The wheel and the pinch now share the same anchored-scale routine, so there is one
    invariant to keep and one to test
    . Two guards: below 24 px of separation the gesture
    only pans (the ratio explodes, and divides by zero in the limit), and the scale per event
    is capped at ×4.

    This has a consequence worth knowing: a swipe that starts on the plane no longer
    scrolls the note. touch-action is set on the canvas only, so swipes that start on the
    margins, above, below, or on the formula panel — 180 of those 264 px when it is open —
    still scroll normally.

  • Nothing that needs a mouse pretends to work with a finger. On touch there is no
    hover, so the crosshair and the cursor cross had nothing to follow, and the rail is
    driven with A/D and W/S on a keyboard that is not there. All three are now off, along
    with the ⌖ button that opened a mode with no way to steer it, and the canvas no longer
    hides the system cursor. Controls that stay grow from 22 to 30 px — not the 44 that
    the touch guidelines ask for, because four 44 px targets over a 321 px plane would take a
    third of its height and bring back the problem we came to solve. The ⓘ popover now rises
    with the chip row and is capped against the plane instead of against fixed numbers, and
    the popover and the formula are mutually exclusive: opening one closes the other, since
    on a phone they overlap almost completely.

    What is lost with this: on a phone there is now no way to read the coordinates of a
    point.
    The crosshair and the rail were the two ways to do it, and both were mouse and
    keyboard. Nothing has replaced them yet; a tap that shows coordinates is the natural
    candidate. A tablet with a Bluetooth keyboard also loses keyboard navigation — there is
    no way to detect that keyboard until a key is pressed.

  • A button to edit the block, because on mobile there is no </>. Obsidian's button
    for reaching the source of a rendered block appears on hover, so on a phone it never
    does, and our canvas takes the taps. The block was left with no door to its own code.
    There is now a ✎ chip in the top-left corner — away from the others on purpose: the ones
    on the top right move the view, the ones at the bottom right open something inside the
    block, and this one leaves the block. It takes the cursor to the end of the block's
    body, not the start, because you press edit to carry on writing; if the note is in
    reading mode it switches to editing first, and it scrolls the cursor into view, which on
    a phone matters because the keyboard covers the lower half of the screen. It hides while
    the formula panel is open, where it would only add noise.

  • The block no longer flashes while it mounts. The formula panel goes through
    MarkdownRenderer, and that has to be awaited before the plane can be built. In that gap
    the browser painted whatever was there — the block with its formula and no graph at
    all
    — and then jumped to the finished block. On the desktop it lasted an instant; on a
    phone, leaving the editor, it was plainly visible. The block now mounts hidden and is
    revealed once the layout is decided, the canvas sized, and the geometry traced and
    painted. It is hidden with visibility and not display:none on purpose: it has to keep
    occupying its place and measuring for real, because the layout is decided from
    clientWidth and the canvas is sized from its real box. There is a two-second safety net
    so that a failure halfway can never leave a block invisible.

The graphing engine itself is untouched: no sampler, tracer or analysis code changed, and
the geometry of every existing case is the same as in 1.2.9. Both suites pass, now with
nine new tests for the gestures — 354 tests in the main suite and 12 in the zoom
suite. Those nine check the properties that are easy to break without noticing: that the
world point under the anchor does not move when pinching (with the anchor deliberately off
centre, where a wrong anchor would go unnoticed), that one pointer still pans exactly as
before, that lifting a finger does not jump, and that a cancelled pointer stops counting.

Verified on Android. The behaviour on iOS has not been tested, and neither has a
tablet.

1.2.9

Choose a tag to compare

@LubrieDev LubrieDev released this 25 Jul 22:10
2a29bcb

1.2.9

A block that froze Obsidian past restarting it, and a plugin that finally has a light theme

One serious bug fix, one long-standing design gap, and two smaller fixes. The
serious one is a graph that could lock the app hard enough to survive quitting
it: reopening the note re-rendered the block and froze it again, so the only way
out was to delete the block with an external editor. The design gap is the light
theme: until now the block was a dark island on a white page, with a formula that
was nearly invisible in it.

  • A block containing tan(e^x) froze Obsidian, and reopening the app froze it
    again.
    This bug was found during testing on a Redmagic 11S Pro and later
    reproduced on Windows. The cause is arithmetic, not hardware: the plane's very
    first paint runs the final, highest-quality pass, and on this function that
    pass generated pathological amounts of work and memory, making the issue
    algorithmic rather than hardware-dependent, regardless of the device's
    performance. Two independent defects met on this one expression. First, the
    explicit sampler had no bound on how much geometry it could produce:
    tan(e^x) oscillates at a local frequency of e^x/π — about 1.5·10⁷
    oscillations per pixel
    at the right edge of the default view — and the
    sampler kept subdividing, ending with 1,084,444 branches and 21,460,279
    vertices after 30.3 seconds and 1.14 GB of heap
    . That case is deliberately
    not covered by the high-frequency envelope added in 1.2.6, whose
    boundedness test rejects it on purpose: an unbounded function has no band
    to draw, it has asymptotes, and it was handed back to the sampler with no
    limit at all. Second, the deduplication of notable points was quadratic, so on
    that geometry it never finished: over 9 minutes and still running when it
    was killed, all of it wasted, since a category with more than 30 points is
    discarded whole.

    The sampler now enforces a refinement budget tied to resolution
    2048 vertices per pixel column, so a phone screen protects itself
    proportionally — and when it runs out the trace does not stop: it stops
    subdividing and continues at the base sampling density, which is already
    bounded, so the curve is still drawn end to end without the sub-pixel detail
    that resolves nothing at that scale. The deduplication now indexes points in a
    grid whose cell equals the tolerance, making it linear while returning exactly
    what the exhaustive scan returned. The first render of tan(e^x) goes from
    30 s, 1.14 GB and a pass that never finished to 705 ms and 112 MB
    (378 ms on a phone-sized canvas).

    Nothing else changes: the budget was calibrated against the existing
    repertoire. The most expensive legitimate case, tan(x²) at ±300, uses
    699 vertices per pixel column, leaving roughly a threefold safety margin,
    and 240 of 240 traced cases (20 expressions × 6 zoom levels × both
    passes) come out bit-identical to 1.2.8, vertex for vertex, with
    identical notable points. Where the drawing does change is inside the
    pathological stretch itself: past x ≈ 10.3 the old picture was a saturated
    black rectangle, every pixel of every column painted, and it is now a lighter
    haze over the same region, without a single pixel drawn that was not drawn
    before. What is lost there is the density of an aliasing smear, not the
    curve itself.

  • The plugin follows your theme, and the formula is legible in a light one.
    The block painted itself #1e1e1e and never set a text colour, so the formula
    inherited --text-normal from the theme: in a dark theme that happens to be
    light text on a dark panel and it worked by accident, and in a light theme it
    was near-black text on a near-black panel — around 2:1 of contrast, where
    4.5:1 is the minimum for legible text. That part was a defect, not a
    preference.

    The fix splits colour in two along the line that matters. The frame
    container, formula panel, buttons, menus, borders — no longer has a colour of
    its own: it derives from Obsidian's own variables (--background-primary,
    --background-secondary, --text-normal, --background-modifier-border,
    --shadow-s), so the block is made of the same material as the note and works
    with any theme, including community ones, with nothing for us to maintain. The
    plot's ink — grid, axes, labels, curves, markers — keeps a palette of its
    own in two hand-tuned versions, because a graph needs guaranteed contrast
    between its layers and no theme variable promises that. The only thing the
    theme is asked is whether it is light or dark.

    The light palette is not the dark one inverted. On white the blue #4f9eff
    washes out and drops to #2f6df6; the grid goes from light grey at 12 % to
    black at 10 %; the axes gain weight, because a faint grey disappears against
    white; and the white halo behind each marker — which separates it from the
    curve on a dark ground — turns dark, since on white a white halo separates
    nothing. Every layer was measured against its own background: axis labels sit
    at 5.03:1 in dark and 5.67:1 in light, and the six curve colours at 5.50–9.30
    and 4.38–5.78 respectively. The six hues are the same in both themes — they are
    the plugin's identity — only darkened for a light ground.

    Switching theme now recolours an open block in place. Curve colours are
    declared by role (which equation they belong to) and resolved against the
    active palette when painting rather than when the scene is built, so a theme
    change is a repaint: your zoom and panning survive it.

  • The same function no longer renders two different ways depending on how you
    typed it.
    Fractional powers were already drawn as radicals — x^{1/2} as
    √x, x^{2/3} as ∛(x²) — but that rewrite only recognised an exponent
    written as a quotient of literal integers, so x^{0.5} came out as
    x^{1/2} in fraction form and x^{0.5φ} as x^{φ/2}. The decimal becomes a
    fraction after the step that does the rewriting, and φ is not a digit.
    The decision now happens in the LaTeX writer, where every form passes through:
    x^{0.5} and x^{1/2} both render √x, x^{1.5} matches x^{3/2}, and
    x^{0.5φ} renders √(x^φ). Exponents with a free variable keep their
    exponential form (e^{x/2} is not a root), and so do negative ones.

  • The options button now closes the menu it opens. The ☰ icon stayed the
    same whether the transformations menu was open or closed, so the button gave
    no sign of what pressing it would do. It now turns into ✕ while the menu is
    open, with its tooltip changing to match, and back to ☰ when it closes,
    including when the menu closes by clicking outside it or by applying one of
    its options. The three blocks that have this menu (obs-graph /
    obs-system, obs-derivate and obs-integral) all follow the same rule,
    each keeping its own description of what the menu contains.

Both suites pass unchanged: 345 tests in the main suite and 12 tests in
the zoom suite.

1.2.8

Choose a tag to compare

@LubrieDev LubrieDev released this 23 Jul 05:30
8367a74

1.2.8

Graph controls: an icon set, one tooltip per control, and a crosshair that survives panning sin(1/x)

Most of this release is cosmetic — a real icon set for the plane's controls, a
single tooltip per control instead of two, zoom buttons that repeat while held,
and the removal of a decorative marker — and it carries two bug fixes: curves
now close against the edge of their domain instead of stopping short of it, and
the crosshair on sin(1/x) no longer goes dark after you pan the view.

  • Curves stopped short of the edge of their domain, leaving a visible gap
    there.
    Reported on the system y = ±⁴√(1−x⁴) — the squircle x⁴+y⁴=1
    which at high zoom showed a hole at (±1, 0) with the stroke cut in two, so the
    near-vertical tip read as a dashed asymptote rather than a curve. Where a curve
    meets the end of its domain with a vertical tangent, the value falls off as a
    root of the distance to the edge: for ⁴√(1−x⁴) it is y ≈ (4ε)^(1/4), which
    descends so slowly that after the sampler's PROF_MAX refinement steps y is
    still ≈7·10⁻³ — tens of pixels once you zoom in — and the branch simply ended
    there. It closed only by luck, when the edge happened to land exactly on the
    sampling grid, which is why the gap appeared at some zoom levels and not
    others, and why it was never a regression: the defect had been latent since
    long before 1.2.6. The sampler already located the edge to machine precision —
    it bisects 40 times to decide whether the discontinuity is a pole — but threw
    that point away instead of drawing it. It is now emitted, attached to the
    finite end and inside the same segment, so the polyline reaches the edge: the
    gap goes from 6.6·10⁻³ (2.5·10⁻² during a gesture) to zero, both passes agree,
    and it stays under a pixel at any zoom. This applies to every explicit curve
    that touches a domain boundary — √x, half-parabolas, and the rest — all of
    which had the same gap, just smaller than the fourth root made it.

  • The crosshair on sin(1/x) stopped appearing after a pan — a regression
    from 1.2.6, unnoticed until now and fixed here.
    1.2.6 began drawing
    stretches that oscillate faster than one pixel as a min/max envelope band,
    marked CalidadRama: "incierta" and carrying no parametro, since a band is
    not a curve you can walk along. The walkability test that gates the crosshair,
    curvaRecorrible, rejects any branch without a parametro — its purpose is to
    catch curves that fold back in x and are therefore multivalued. A band trips
    that test even though it is not a fold, so the presence of a single band
    disabled the crosshair for the whole curve. It surfaced on sin(1/x)
    specifically, and only after panning: the band near x=0 forms or not depending
    on how the sampling grid lands on the pixels, which shifts as the view moves,
    so a settled default view resolved cleanly while a panned one did not — and a
    wheel zoom, which resolves the oscillation as it magnifies, never triggered it
    either. The test now excludes "incierta" bands from its judgement, exactly as
    the crosshair's own yEnRamas already skips them: the crosshair works over the
    rest of the curve and is simply not drawn over the band itself, where there is
    no single y to report. Before 1.2.6 the curve was traced as ordinary branches
    throughout and was always walkable, so this restores the earlier behavior.

  • The plane's controls now use a real icon set. Home, zoom-in, zoom-out,
    trace, info and the options menu were text glyphs and emoji (🏠, +, −, ⌖, ⓘ,
    ☰), and the pointer over the plane was a hand-drawn cross. All are now Material
    Symbols, drawn as inline SVG through Obsidian's DOM API rather than
    innerHTML, and filled with currentColor so each icon inherits its button's
    colour and follows the active/inactive highlight unchanged. The pointer icon is
    drawn on the canvas as a Path2D, built lazily so it costs nothing in the Node
    test bundle, which never paints.

  • The zoom buttons repeat while held. Pressing + or − zoomed a single notch
    and had to be clicked again for every further step. They now keep zooming for
    as long as the button is held, at a steady cadence, reusing the same centred,
    smoothed zoom a single press already used — so holding reads as one continuous
    zoom rather than a stack of steps. A quick tap still does exactly one notch.

  • One tooltip per control, above it rather than below. The options button
    carried both a title attribute and an aria-label, so hovering it produced
    two tooltips at once — the browser's native one and Obsidian's. Every control
    now uses Obsidian's setTooltip with top placement and no title, so there is
    a single dark tooltip, positioned above the control where the pointer does not
    cover it.

  • The experimental-engine ⚙ marker is gone. The small gear in the top-right
    corner of the plane was a decorative label; it has been removed along with its
    now-unused translation strings, and the zoom buttons moved up to take its
    place.

Both suites pass unchanged — 345 tests in the main suite and 12 in the zoom
suite.

1.2.7

Choose a tag to compare

@LubrieDev LubrieDev released this 22 Jul 20:13
771a337

1.2.7

No generated code: the expression compiler no longer uses new Function

A maintenance release with a single change. 1.2.6 introduced a native
expression compiler that made tracing 2.3× to 18× faster by generating
JavaScript source at runtime and compiling it with the Function
constructor. That is dynamic code execution: it requires unsafe-eval in the
Content Security Policy, and it means the plugin's behavior cannot be
determined by reading the plugin's code, since part of what runs is a string
assembled while it runs. This release removes it without giving up the speed.

  • Expressions are now compiled to a tree of closures instead of to source
    code.
    The compiler walks the same mathjs AST as before, but each node
    becomes a nested JavaScript function that has already resolved — closed over
    — which operation it performs and what its operands are. Evaluating means
    descending that tree calling ordinary functions; there is no typed-function
    dispatch, no scope object and no name lookup left in the sampling loop, which
    is where the original speedup came from. This is closure compilation: the
    work that depends only on the expression is done once, and only the work
    that depends on the point remains per sample. No string is ever turned into
    a program, so eval and the Function constructor are both gone from the
    plugin, and unsafe-eval is no longer needed.

    Compiled closures take a fixed two-argument signature even when the
    expression has one variable. That is deliberate: it avoids allocating an
    argument array on every evaluation and keeps the call sites inside the tree
    monomorphic, which is most of the difference between a closure tree that is
    fast and one that is not.

    Closures are somewhat slower than generated source on large expressions, and
    measuring that honestly matters more than the headline. Against the 1.2.6
    implementation, over 300,000 evaluations per expression: sin x 12.7 ms →
    11.8, x³−3x+1/x 14.0 → 15.1, (x²+y²−1)³−x²y³ 19.1 → 17.9,
    sin(1/x)·e^(−x²)+ln(|x|+1) 16.2 → 39.0, sin(xy)+cos x/(1+y²) 29.3 →
    63.1. So the worst case measured gives up a factor of 2.4 against generated
    code — while still evaluating 8.4× to 26.8× faster than mathjs, which is the
    comparison that decides frame time, since mathjs takes 222 ms to 530 ms on
    those same runs. The tracing speedup of 1.2.6 therefore stands.

    The three safeguards are unchanged and still apply: a whitelist that refuses
    anything whose semantics have not been verified against mathjs, a
    differential validation of the compiled function against mathjs over ~40
    probe points before it is used, and a fallback to the mathjs path whenever
    either fails.

No behavior changes: both suites pass unmodified — 345 tests in the main suite
and 12 in the zoom suite — which between them cover the tracing geometry the
compiler feeds.

1.2.6

Choose a tag to compare

@LubrieDev LubrieDev released this 22 Jul 07:31
a9ad347

1.2.6

Graphing engine: speed, and fewer curves that change while you move the view

This release is about the graphing engine. It makes evaluation 2.3× to 18×
faster, addresses three visual defects reported while panning and zooming, and
bounds the curvature of the parametric and implicit tracers, which were drawing
smooth curves as visible polygons. It also unlocks the explicit sampler, which a
test had frozen against a dead copy of itself.

Two of those three defects are fixed outright; the third, the flicker on
sin(1/x), is greatly reduced rather than removed. A fourth — the moving
stripes on tan(x²) — is diagnosed but not fixed, and is described at the end.

  • Expressions are compiled to native JavaScript: 2.3×–18× faster tracing.
    mathjs remains the parser — its AST is still the source of truth for the
    syntax — but it is no longer the evaluator. mathjs.compile().evaluate(scope)
    pays typed-function dispatch and scope construction on every sample, and a
    trace takes between 2,500 and 220,000 evaluations, so that wrapper was the
    bulk of the frame rather than the mathematics. src/compiladorNativo.ts walks
    the AST and generates the equivalent JS, compiled once with new Function.
    Measured over the complete tracer: √(9−x²) 12.5 ms → 1.0 ms, ln|x|
    12.6 → 1.0, sin x 13.2 → 1.3, tan x at zoom-out 333.7 → 52.5,
    sin(1/x) deep-zoomed 585.8 → 85.6. Geometry came out bit-identical — same
    branches, same vertices — in all 160 comparisons made (8 curves × 10 zoom
    levels × both passes); that is a broad check, not a proof for every possible
    expression, which is what the safeguards below are for. Three of them keep an
    acceleration from ever becoming a change of drawing: a whitelist that refuses
    to generate code for
    any node whose semantics have not been verified against mathjs; a differential
    validation of the generated function against mathjs over ~40 probe points
    before it is used; and a fallback to the old path when either fails. Note that
    mathjs 12 does not use eval or new Function, so this is a genuinely new
    capability the plugin exercises where the environment allows it. Degradation
    is clean by construction: the call sits inside a try, so a Content Security
    Policy that blocks it returns null, every expression falls back to mathjs,
    and the engine behaves exactly as before — verified by sabotaging the
    Function constructor and re-rendering the curves in the test repertoire.

  • tan(y) = x drew short spurious strokes across its branches. The
    separable-implicit provider rescues thin slivers next to each pole, because
    regular sampling can miss a branch that only exists within a fraction of a
    pixel. That rescue ran unconditionally, so where the ordinary sampling had
    already covered a pole it added a second, much coarser trace of the same
    place: 8 extra branches of 6 or 7 points each, which is what showed up as
    marks over the curve. The rescue now consults what was already traced and
    skips poles that are covered, using a sorted index of the visible x-values and
    a binary search rather than a scan per pole. Real slivers are still rescued —
    there is a regression test for a pole where sampling genuinely misses the
    branch.

  • sin(1/x) flickers far less when zooming — reduced, not eliminated.
    Where a curve oscillates faster than one pixel, no sampling density resolves
    it: several whole cycles
    fall between consecutive samples, so what gets drawn is an arbitrary subset
    of a dense band — and which subset depends on the number of samples. Since the
    interactive pass and the final pass use different densities, each picked
    different threads and the curve changed when the gesture ended: measured, 122
    branches interactive against 38 final, with 31% of pixels differing. Those
    stretches are now detected and drawn as the min/max envelope per pixel column,
    on a grid pinned to the pixels and with a fixed number of samples per column,
    so both passes compute exactly the same thing. Flicker went from 30.9% to 0.5%
    on average, with a 6.6% peak — a large reduction, but not zero: some change
    between passes remains, and the surrounding curve is still sampled the usual
    way. For scale, measures 14.3% on the same metric purely from rasterising
    a curve that legitimately moves, so the residue sits below that floor. The
    deep-zoom case also got 6.8× faster (586 ms → 86 ms) because the sampler stops
    trying to resolve the unresolvable. These bands are marked
    CalidadRama: "incierta" — the contract value that had never been emitted —
    and carry no parametro, since a band is not a curve you can walk along.
    Telling a pole from an oscillation is what made this hard: inside the
    column holding a pole, the function rises, jumps and rises again, which reads
    as the same signature as a cycle. Neither a threshold on turning points nor
    the shape of the sign change is reliable; what works is the combination of
    four or more turning points with the local and global max/median ratio,
    since unboundedness is a property of the function rather than of the sampling.

  • Smooth parametric and polar curves were drawn as polygons. Reported on
    r = sin(θ/10): faceted while zooming, smooth once stopped. Refinement used
    only a deviation test — the distance from the midpoint to the chord — and
    deviation is a sagitta, which scales with the square of the chord: on an arc
    of screen radius R a turn of θ leaves a sagitta of only R(1−cos(θ/2)), so with
    the curve small on screen a vertex can turn 36° and still pass a 1-pixel
    threshold. Measured, the turn angles came out quantised at 36/18/9/4.5° —
    exact powers of two, that is, the uniform-sampling polyline never refined once
    — with a mean turn per vertex of 19.7° interactive and 9.9° final. A curvature
    criterion now bounds the turn itself, and the worst facet measured is 3.93°
    across the curves tested — a polar rose, a cardioid, a circle, a Lissajous
    figure, a spiral and a parabola, each at six zoom levels in both passes — at a
    cost of at most 2.5 ms. Two details matter: the measured angle is doubled
    before comparison, because an accepted segment
    discards its midpoint and emits the whole chord, so the angle actually drawn
    at the joint is about twice the one measured; and the criterion switches off
    below a 1.5-pixel chord, where a kink fits inside the stroke width and where
    chasing curvature would never terminate at a genuine cusp such as the r=0 of a
    rose.

  • The same blind spot in implicit curves. The continuation tracer advanced
    with a fixed arc-length step — 4.5 px interactive, 2.5 px final — and its
    existing turn test is a validity check at ~45°, which bounds nothing visible.
    Measured turn per vertex during a gesture: 17.7° on the folium x³+y³=3xy,
    7.8° on an ellipse, 6.5° on a circle. A smoothness criterion now reuses the
    step-halving loop that was already there, bringing those to 3.9°, 3.7° and
    3.8°, with no vertex above 5° on those three. It does not bound every curve:
    x²y²=1 zoomed out still leaves 6.9° at two vertices during a gesture, where
    the shorter steps are rejected by the chord test and the fallback below is
    what gets used. It cannot make things worse: the first valid step is kept as
    a fallback and returned when no scale manages to be smooth, so
    the function never returns null where it previously returned a step, which
    would have triggered spurious straight-line crossings or cut branches short.
    Explicit curves were checked too and needed nothing — they sample per pixel
    column, so their turns were already under 2°.

  • Parametric curves were drawn as a single straight line where they left the
    view.
    On crossing the visibility boundary the tracer bisected to find the
    edge and emitted only that point, so the whole visible arc leading up to it
    became one chord. It bites as soon as zoom leaves the visible portion inside a
    single step of the initial sampling in t. Measured on r = sin(θ/10) at
    semiY=0.005: 7 points and 37.6 px of deviation interactive, against 519 points
    and 0.07 px final. The visible arc is now refined with the normal logic:
    515 points and 0.07 px in both passes, and the deeper zooms where both
    passes were broken are fixed as well.

  • The explicit sampler was frozen by a parity test against a dead copy of
    itself.
    tests/modulos/trazado.test.ts required vertex-by-vertex equality
    with src/render/muestreoExplicito.ts — the legacy GraphEngine sampler, which
    no longer draws anything and which the two copies had already outgrown. That
    made the legacy code the definition of correct, and broke any improvement to
    sampling or refinement by construction, even one that did not move a single
    pixel: capping refinement below the pixel failed with "expected 4146 points,
    got 4090", a 1.4% difference in vertices all of them sub-pixel. The test now
    compares what is observable — same branch count, same vertical asymptotes
    measured in pixels, and the two curves within 0.25 px of each other on
    screen, as a two-way Hausdorff distance against the segments rather than the
    vertices. It still catches genuine regressions, verified by sabotage: nearly
    disabling refinement produces 6 failures and cutting the sample count by 8×
    produces 22.

Existing behavior is unchanged elsewhere: the full suite is now 345 tests, the
zoom suite 12, and both pass. Every performance figure above was measured with
medians over repeated runs on jittered viewports, so caching is not being timed.

Known and not fixed

Functions whose local frequency grows, such as tan(x²), still show regularly
spaced dark stripes when zoomed out, and the pattern changes between the
interactive and the final pass....

Read more

1.2.5

Choose a tag to compare

@LubrieDev LubrieDev released this 22 Jul 00:08
da1cd80

1.2.5

Maintenance

Maintenance release with no user-facing changes. It reorganises the test
suite, which had grown into a single 4838-line file, and restores the type
check, which had been silently disabled by an invalid compiler option — closing
the two type holes it had been hiding. No graphing, equation-solving,
derivative, integral, or parsing behavior was changed: the shipped main.js is
byte-for-byte identical to the one built before these changes, verified with
a full rebuild and a binary comparison.

  • The test suite is split by topic. tests/motor.test.ts held 60 blocks in
    one 4838-line file, which made a failing block hard to locate and every edit a
    conflict magnet. It is now an index that loads eight modules from
    tests/modulos/trazado, carril, implicitas, despeje,
    parser-latex, calculo, parametricas, escena — plus a comun.ts with
    the shared viewport, tolerances and helpers. The runner keeps a module-level
    pass/fail count, so the modules are imported for their side effects and the
    summary is still printed exactly once; the suite remains a single bundle and a
    single npm run test. Purely a move: the body of every block is unchanged,
    and the 334 test names and results are identical to before the split, verified
    by diffing the runner output of both versions.

  • The type check was not running at all. tsconfig.json carried
    "ignoreDeprecations": "6.0", a value TypeScript 5.9 rejects outright
    (error TS5103), so tsc --noEmit aborted on the config before checking a
    single file — and since the build is esbuild, which does not type-check, no
    type error had been visible for some time. That option was silencing two
    options deprecated for removal in TypeScript 7.0, so rather than silence them
    the options themselves were retired: baseUrl was dead weight (there are no
    paths, and the only non-relative imports are the obsidian and mathjs
    packages), and moduleResolution moved from the deprecated node (node10)
    to bundler, which is what esbuild actually does. With nothing deprecated
    left, ignoreDeprecations was dropped. skipLibCheck was enabled so that
    three pre-existing errors inside obsidian.d.ts itself do not keep the
    command red. npx tsc --noEmit now reports zero errors and is usable as a
    gate again, in the editor as well as on the command line.

  • FontFaceSet.add was untyped: "DOM.Iterable" added to lib. The
    set-like methods of FontFaceSet are declared in lib.dom.iterable.d.ts
    (interface FontFaceSet extends Set<FontFace>), not in lib.dom.d.ts, so
    document.fonts.add(cara) in the font loader resolved to nothing —
    error TS2339: Property 'add' does not exist on type 'FontFaceSet', which is
    also what ESLint reported as no-unsafe-call. The method exists at runtime, so
    font loading always worked; only the types were blind to it. Fixed in the
    compiler options rather than at the call site, so the whole DOM surface is
    typed consistently.

  • explicita() returned a type wider than what it builds. It is annotated
    as returning the union ObjetoMatematico, so { ...f, salida: "x" } — the
    tumbled reading of an x(t) parametric component — was checked against every
    member of the union and rejected for excess property, even though salida is
    declared on ObjetoExplicito. The return type is narrowed to
    ObjetoExplicito, which is what the function actually constructs. Type-only:
    annotations are erased on emit and the bundle is unchanged.

  • Known and left alone: the display() deprecation. Obsidian deprecated
    PluginSettingTab.display() in 1.13.0 in favour of getSettingDefinitions(),
    and the settings tab still calls it. This is deliberate — the manifest declares
    minAppVersion: 1.12.7, and the API documents display() as the fallback for
    plugins supporting earlier versions. It will be revisited when the minimum
    version moves to 1.13.

Existing behavior is unchanged: the full suite (334 tests), the zoom suite (12)
and the graduated battery (220 generated towers, 0 failures) pass exactly as
before.

1.2.4

Choose a tag to compare

@LubrieDev LubrieDev released this 21 Jul 21:11
df1d5ac

1.2.4

Features

Minor release that widens solving for y past the "one occurrence of y"
frontier of 1.2.3, and gives obs-integral the quotient rule it was missing.
The new solver paths are general rewrites over the AST — clear the denominators,
collect the linear coefficient, square away the radicals — not per-equation
cases. Every transformation that is not an equivalence now states its condition
or is rejected: a solved form still never claims a point the curve doesn't have.
And the conditions that survive are now solved as a system instead of listed,
so a domain reads x ≥ \sqrt3 rather than as two raw inequalities. The full test
suite (334 tests), the zoom suite (12) and the graduated battery (220 generated
towers) pass.

  • y no longer has to occur once — it has to be grouped. The structural
    inverter peels the outermost layer and recurses into the child holding y,
    which never needed y to be unique: the whole layer is inverted at once, so it
    is enough that a single child contains it. When the tower gets stuck — y
    split across both branches of an operator — what has been peeled so far is an
    equivalent, simpler equation, and it is re-solved from scratch:
    \ln\frac{y-1}{y+2}=x → y=\frac{2e^x+1}{1-e^x},
    e^{\frac{y-1}{y+2}}=x → y=\frac{2\ln x+1}{1-\ln x},
    \ln(y^2+y)=x → y=\frac{-1±\sqrt{4e^x+1}}{2}.

  • y in a denominator is cleared and the equation re-solved. No additive
    strategy could reach a y sitting in a factor of exponent −1 unless the
    numerator was free of y. Multiplying through by the denominators — the first
    step of any textbook solution — leaves a polynomial equation the existing
    machinery finishes: \frac{y-1}{y+2}=x → y=\frac{2x+1}{-x+1}.

  • Equations affine in y whose coefficient needs expanding. A sum of terms
    each carrying a bare y (y\ln x+y=3) already worked; what fell through was
    y inside a group that has to be distributed first, when the coefficient is
    not polynomial — the linear strategy reads the coefficient off the structure
    of the term, so y has to be a bare factor, and the quadratic path needs
    rationalize to expand, which only works on polynomials. Since an affine
    function is fixed by two values, A and B are now recovered by
    substituting two values of y — substitution works with any coefficient, no
    expansion needed — and the affinity itself is what gets checked numerically:
    y-(y+2)e^x-1=0 → y=\frac{2e^x+1}{1-e^x} (this is the shape the cleared
    Möbius above lands on, and it is what makes that case finish).

  • Scattered square roots are rationalized by successive squaring, carrying the
    domain condition of every step.
    One isolated root was already invertible, but
    two roots — or a root plus y outside it — leave y ungrouped and no layer
    peelable. Isolating one root and squaring, repeated, produces a polynomial the
    solver finishes. Squaring is not an equivalence (A=B ⟺ A²=B² and
    B≥0), so each step records its guard and all of them travel to the result as
    domain conditions, written in x by substituting back what the later steps
    determined: \sqrt{y+1}+\sqrt{y-2}=x → y=\frac{x^4+2x^2+9}{4x^2} with
    x ≥ \sqrt3 (the two raw guards, resolved into one by the condition
    simplifier below). Verified point by point: the solved form draws nothing for
    x<\sqrt3 and has no gap above it.

  • Domain conditions are now solved as a system, not listed one by one. Each
    restricted-range layer and each squaring step contributes its own guard, and
    the panel used to print all of them — correct but unreadable, because they are
    inequalities over the same x and nobody was looking at the system as a whole.
    A new module resolves each condition by its sign table (the zeros of
    numerator and denominator split the line into constant-sign runs) and
    intersects the results, which is where redundant conditions vanish on their
    own, adjacent runs merge, and contradictions surface as an empty intersection.
    So \frac{x^2+3}{2x}≥0,\ \frac{x^2-3}{2x}≥0 is displayed as what it says:
    x ≥ \sqrt3. Critical points are computed symbolically precisely because
    they are what gets displayed — the root of x^2-3 has to read \sqrt3, not
    1.7320508 — so -x^2+2 ≥ 0 becomes -\sqrt2 ≤ x ≤ \sqrt2 and x-27 ≥ 0
    becomes x ≥ 27. Its declared reach is closed-form roots: degree 1, degree 2
    by the general formula (with the square factor pulled out of the radical,
    \sqrt{12}=2\sqrt3), and higher degrees only where integer roots deflate them.
    Anything else — a guard with \tan x, |x|, \sqrt x, or a solution set made
    of disjoint pieces — leaves the conditions displayed exactly as before: the
    module's failure mode is "I don't simplify", never "I simplify wrongly". It is
    presentation only; the engine keeps evaluating the original guards, so what is
    drawn does not change.

  • Contradictory guards are detected across the whole system. Each guard can
    be satisfiable on its own while the system is not, and the per-guard check
    could not see that. An empty intersection now means there is no real curve and
    the equation is left in its reduced form instead of carrying a formula that
    never applies.

  • obs-integral: the logarithmic derivative ∫\frac{c·q'}{q}=c\ln|q|. A
    quotient with x in both numerator and denominator always returned "no
    antiderivative". The ratio p/q' is measured by finite differences rather than
    by symbolic differentiation, so it does not depend on mathjs being able to
    differentiate \csc or \abs. This covers ∫\frac{2x}{x^2+1},
    ∫\frac{3x^2+2}{x^3+2x-5}, ∫\cot, ∫\frac{f'}{f} in general.

  • obs-integral: trigonometric canonicalisation as a retry. Expressions
    written "by identity" reduce to something the structural rules recognise once
    \csc/\sec/\cot/\tan are rewritten in \sin/\cos and the double angle is
    opened: ∫\frac{1}{\csc 2x-\cot 2x} collapses to ∫\cot x = \ln|\sin x|. The
    original form is always integrated first, so ∫\sin 2x still gives
    -\frac{\cos 2x}{2} and not the expanded version. Also adds the missing
    \cot, \sec and \csc table entries (with linear substitution:
    ∫\cot 3x = \frac{\ln|\sin 3x|}{3}).

Bug fixes

  • Clearing a denominator no longer invents branches. Multiplying by q only
    preserves the curve where q≠0, and the cleared equation is defined there —
    so it can carry solutions that do not exist. \frac{y^2-1}{y-1}=x clears to
    y^2-1=x(y-1), whose roots are y=x-1 and y=1, and the second is not
    curve at all (the original is 0/0 at y=1). Since a condition cannot be
    written with the existing ≥0 domain sentinel, the candidate is instead
    validated branch by branch against the equation before multiplying — the only
    one that knows about its own holes — and the whole solution is dropped if any
    branch contradicts it.

  • Removable holes are no longer silently filled. \frac{y^2-4}{y+2}=x is
    affine in y throughout its domain and solves to y=x+2, but the curve does
    not contain y=-2, i.e. the solved form is missing its hole at x=-4 and is
    laxer than the curve. Same validation, same outcome: the equation is left in
    its reduced form rather than stated more loosely than it is true.

  • A singular sampling point no longer leaks into the formula. Recovering the
    affine coefficients by substituting y=0,1 hits 0/0 on an equation like
    \frac{y^2-1}{y-1}, and the resulting Infinity travelled all the way into
    the displayed solution (y = ∞x + ∞). Several sampling pairs are tried until
    one is clean, and a non-finite coefficient rejects the pair.

  • Both branches of a ± are now validated, not just the principal one. The
    numeric check evaluated the ± sentinel at its principal value, so the second
    branch entered the result unchecked. Validation now runs over the expanded
    branches — exactly what the engine will draw. A branch that its own domain
    guard empties is not a failure (that is precisely the fate of the extraneous
    branch introduced by squaring); a branch that contradicts the equation is.