Skip to content

Releases: LubrieDev/lmath

1.5.0

Choose a tag to compare

@LubrieDev LubrieDev released this 17 Aug 02:08
334889b

1.5.0

The block syntax changes. obs-graph becomes _graph, and the same for the other five.

Nothing you already wrote breaks in this release. Both names render, side by side, so notes
written before today keep working after you update.

⚠️ Read this before skipping the update

1.5.0 is the only release that can convert your notes for you.

In 2.0.0 the old obs-* names stop being accepted completely — a note still written
obs-graph will not render, it will sit there as a plain code block — and the Update notes
button is removed in that same release
. There will be nothing left to convert your notes
automatically; the alternative will be editing every fence by hand.

If you have blocks written the old way, open Settings → LMath → Update notes while you are
on 1.5.0. It takes one click and asks before writing anything.

That warning is the whole reason this version exists. The migration — the button, the startup
notice, the tool behind them — is temporary scaffolding to get from the old syntax to the new one,
and it is all removed in 2.0.0 together with the syntax it exists to leave behind.


The rename

Until 1.4.0 From 1.5.0
obs-graph _graph
obs-system _system
obs-derivate _derivate
obs-integral _integral
obs-trig _trig
obs-vector _vector

Why change them at all. A code-block identifier is a global key shared by every plugin you
have installed. graph bare is among the easiest names for someone else to have taken, and
whoever loses that draw stops rendering with no warning to anybody. The names needed a prefix that
marks them as this plugin's without being a word another plugin would want.

Why an underscore, specifically. The first attempt was graph*, and it did not work: Obsidian
does not preserve the asterisk from a fence's info string, so the key the plugin registered never
matched what was written in the note. \graph and .graph fail the same way — the identifier has
to stay inside the character set Obsidian accepts. The underscore does, and still marks the block
at a glance.

Both names work for now, and that ordering is deliberate. If the old identifier stopped
rendering in the same release that introduces the new one, notes would break before anyone could
press the button that fixes them. The migration button only makes sense on a plugin that already
accepts both syntaxes.

Updating your notes

Settings → LMath → Update notes. It scans the vault, shows you what it found — how many notes,
how many blocks, which files — and asks before writing anything. Files are written one at a time
through Obsidian's own atomic path, and only the fence line changes; nothing inside a block is
touched. If you cancel at the summary, nothing has been written.

You do not have to run it the moment you update — the old names keep rendering for as long as
1.5.0 is what you have installed. But 1.5.0 is the last release where the button exists, so
running it before you move on to 2.0.0 is the difference between one click and editing every
fence by hand.

It has been run by hand over a vault containing 300 LMath blocks, and all 300 were rewritten
correctly. That is a check on real notes, not part of the automated suite quoted at the end of
these notes; the scanner underneath it has 19 tests of its own.

There is also a notice at startup describing the change. It appears on every load, which is
deliberate and not a bug: it used to appear once per install, and start-up is exactly the moment
when several notices are stacked on top of each other, so the only one there was got spent without
anyone reading it and never came back. It goes away with the rest of the migration in 2.0.0.

A system written with ± was listing half its solutions

y = ±⁴√(1−x⁴) is not one curve, it is two. The plot always knew that — it draws both halves —
but the solver behind the ⓘ panel paired the equations as written and only ever crossed one of
them. Against y = ∛x, whose two curves are odd and therefore meet in a symmetric pair, the panel
named one point while the plot drew the crossing of the other.

The double sign is now expanded before any pairing happens, through the same code the plot uses.
Branches stay grouped by the equation they came from and are crossed group against group, never a
branch against its own sibling: where the two halves meet — the radicand at zero — the curve is
closing on itself, not crossing another one, and listing that point would be inventing a solution.

Measured over 162 hand-written blocks from a real vault, this changed that one system and no
other.

\sin{…} now accepts braces inside its argument

\cos{\sin{x}}, \sin{\sqrt{x}} and anything else with a braced group inside a trigonometric
function's braced argument did not render at all — the block came up empty, with no error.

The rule that converts \sin{ARG} could not match an argument containing braces, and it ran once,
outward in: in \cos{\tan{x}} it converted the inner \tan and left the outer \cos{ broken.
Repeating the pass would not have helped, because radicals are converted later in the pipeline,
so \sin{\sqrt{x}} still has the root's braces at that point. It now counts brace levels, which
is what \ln{…} and \log{…} have done for a while.

The same change covers the hyperbolic family, which had never been in that rule at all:
\sinh{x}, \cosh{x}, \tanh{x}, \sech{x}, \csch{x} and \coth{x} with braces did not work
either. Their arguments are not converted to degrees, unlike the trigonometric ones — \sinh{30}
is 30, \sin{30} is 30°.

The ⓘ box no longer jumps when you open it

Opening an ⓘ panel showed it briefly stretched to full height with a scrollbar, which then
collapsed to the right size a moment later. Its lines are typeset with KaTeX, and until that
finished each line was still a paragraph carrying its own margins — enough to push five lines past
the box's maximum height. The box now measures the same before and after, so it opens at its final
size.

Colour

The block used to take its panel colour from the theme's secondary background, which is lighter
than the primary in some themes and darker in others — so depending on your theme, the same
release rendered either as a recessed card or as a pale grey box around a dark plot.

The chrome is now three layers, all defined against the note itself: the plot surface is the note's
background, the area around the formula sits a step above it, and the formula card and the ⓘ box
sit a step below. It follows any theme, light or dark, and the relationship between the layers
stays the same in all of them.

Also in this release

  • A _derivate or _integral block still shows its formula; the ⓘ panels of every block continue
    to typeset their mathematics with KaTeX rather than as plain text.
  • Documentation rewritten for 1.5.0: the technical reference gains sections on the rename and its
    tool, on the colour tokens, and on the ± expansion.

Known limitations

These are real and not fixed in this release:

  • A _derivate block does not understand the {0 ≤ x ≤ 2π} domain restriction, which
    _graph does. Written with one, the block reports no function.
  • A _derivate or _integral block with a parameter declaration (A = 2 on its own line)
    does not work: parameters are a _graph/_system feature, and in these two blocks the
    declaration is taken as the block's first equation.
  • A parameter cannot be defined from another parameter. B = 2A is not read as a
    declaration, and the block draws nothing.
  • An integral whose interval contains two poles that cancel reports 0 rather than
    divergent. ∫₀^π cot x dx is the case: each half diverges — the plugin says so if you ask for
    either half on its own — but over the whole interval the two infinities cancel into a Cauchy
    principal value, which is not the value of the integral.

Compatibility

minAppVersion stays at 1.13.0. Blocks written with obs-* render exactly as before.

1.4.0

Choose a tag to compare

@LubrieDev LubrieDev released this 15 Aug 21:55
46c83df

Version 1.4.0

This release has two halves.

The first is a new block, obs-vector. Like obs-trig, it does not plot a function; unlike
every other block, it shows one card per line instead of one formula per block. It typesets
vector notation the way you would write it on paper, and it has an panel for what follows
from what you wrote.

The second is a math engine, and it exists to correct a design decision that was wrong. The
solutions a system reported were taken from the plotted curves — the crossings of the drawn
polylines, clipped to whatever part of the plane you happened to be looking at. That is why an
intersection at the origin would read (0, 0) and then (8.4e-6, 8.4e-6) after you moved the
plane, and why a solution outside the view did not exist at all. Asking the drawing something only
the equations know was the mistake. Solutions are now computed from the equations, and for a
polynomial system they are exact and do not change when you zoom. The same defect, smaller, was in
the crosshair; that is corrected for explicit curves and described honestly below.

Around those two there is a third language, Portuguese; a fix to the ⓘ panels of obs-trig and
obs-vector, which closed themselves when you clicked anywhere; and a reorganisation of the source
that changes nothing you can see and is described at the end for whoever reads the code.

No existing block changes its syntax or draws a different curve. The things that do reach the five
published blocks are listed under Compatibility. minAppVersion stays at
1.13.0.


The new block: obs-vector

One line, one card

The block has no syntax of options: what a line means comes from the shape it has, and nothing is
resolved for you. The case of the first letter is the whole rule.

```obs-vector
v = (3,2)
A = (1,2)
F(x,y) = (-y, x)
```
you write it is typeset as drawn as
v = (3,2) a vector — lowercase \vec{v}, the real filled arrow of KaTeX an arrow from the origin
A = (1,2) a point — UPPERCASE A, bare a dot
F(x,y) = (-y,x) a vector field — it has arguments F(x,y), as written nothing
AB, A->B, A → B, \vec{AB} the vector between two declared points \overrightarrow{AB} an arrow from A to B
anything else free notation as written nothing

A name of two or more letters takes \overrightarrow, not \vec. \vec is a short mark
centred on one glyph: over AB it reads as the arrow of the A, with the B left outside.
Subscripts and primes decorate a single variable, so v_1 and u' keep \vec.

Two things override the case rule, both deliberately: arguments win, so f(x,y) = (-y,x) is a
field even in lowercase; and an arrow you wrote yourself wins, so \vec{A} = (1,2) really is a
vector. Writing \vec{v} = (3,2) is not doubled up.

A point is not drawn as an arrow from the origin. It is a dot. A point is not a position vector
unless you say so, and drawing it as an arrow would state something the author did not write.

The vector between two points

AB on its own line resolves to the vector from A to B, provided both are declared in the same
block — in any order, so AB may come first.

```obs-vector
A = (1,2)
B = (5,4)
AB
```

That block draws the two dots and the arrow that joins them, and puts the declarations and the
result on two separate views (below). If the two points are not both declared, AB is simply
the product A·B and is typeset as such: the block never invents coordinates you did not write.

Anything it does not recognise is still typeset

A line the block cannot classify gets its card anyway, which is what makes it usable for notation
the engine cannot evaluate. A LaTeX command LMath does not support is handed to KaTeX untouched
instead of being reported as an unsupported symbol:

```obs-vector
\nabla f(x,y)
```

Untouched is literal, and it applies only to that case. Every other line goes through the
plugin's ordinary pipeline, which normalizes before typesetting and there inserts the implicit
product it thinks it sees between a name and an opening parenthesis. So an unclassified line that
looks like a function call shows a stray : ∇f(x,y) with the Unicode symbol comes out
∇f∗(x,y), and G(x,y) = -y comes out G∗(x,y) = −y. The clean forms are the LaTeX command
(\nabla f(x,y)) and, for a function of two variables, a pair on the right — which is exactly what
makes it a field.

Components accept everything the rest of the plugin accepts (\frac{1}{2}, 2\pi, -\sqrt{2},
3), and both (3,2) and [3,2] are read as pairs.

The plane is always there, and says when it is empty

The view is computed once from the vectors themselves and has no camera: there is no panning,
no zooming and no dragging here, because a finite set of arrows is fully known in advance and there
is nothing to explore. Each card and its arrow share the colour of their line.

When no line has numbers to draw, the plane is dimmed and tells you why instead of sitting
there empty:

  • an empty block says No vector, and the card still shows the shape it expects, \vec{v} = […];
  • a block that writes something undrawable — a field F(x,y), a gradient ∇f(x,y), an unresolved
    w = u + v — says Nothing to draw. Those lines are typeset above; they simply are not arrows.

An empty plane that says nothing looks like a broken block, and hiding it made the same block look
like two different blocks depending on what you had written.

The labels are the same letters as the cards

The name beside an arrow is rendered with KaTeX over the canvas, not drawn on it. The \vec{v}
on the plane is the very same LaTeX as the \vec{v} on its card — the same filled arrow, the same
italic — and \overrightarrow{AB} keeps the long arrow that stretches over both letters. A canvas
fillText can only put the system's italic there, and next to a real formula it shows.

What is not drawn is not labelled either: a field and a free line have no mark on the plane.

ⓘ — what follows from what you wrote

When there is a plane there is an ⓘ on it. It reports what can be deduced from the arrows and
dots already drawn — properties of what is there, not new objects. There is no u+v in it, because
you did not write one. That is the same line the cards do not cross, applied to the panel.

  • One collapsible section per vector, headed by the name you gave it: its x and y, its
    magnitude, its direction, the quadrant or semiaxis it falls in, and its unit vector. For AB the
    components are the difference B − A, which is not the pair of any card.
  • With exactly two vectors, one further section: dot product, angle between them, determinant,
    the area of the parallelogram they span and that of the triangle — and, when it holds,
    Perpendicular or Parallel. With exactly two points: distance and midpoint.
  • Two and only two. Five vectors make ten pairs, and the panel would become a matrix nobody
    reads; the block will not choose a pair for you.
  • Values are exact when they were earned: with integer components the magnitude of (3,2) is
    √13 ≈ 3.606, with the largest square taken out of the radicand (√12 is written 2√3).
    (0.5, 1.3) gets the decimal alone. It is the discipline of obs-trig, applied to the only
    provenance this block can check.
  • Angles are written by the same function that labels obs-trig, so they follow the Angle
    unit
    setting and come with their exact form when they have one. The angle between two vectors is
    computed as atan2(|det|, dot) and not with the arccosine of the textbook formula, which loses
    its precision exactly at 0 and π — the two cases a reader would check first.

Two views when the block deduces something

A line like AB is not of the same order as A = (1,2): the second declares, the first asks for a
result. When both are present, the panel separates them behind the button bar obs-derivate and
obs-integral already use — the main button shows what the block declares, one card each, and
the menu (☰) leads to \overrightarrow{AB} alone.

It also fixes a matter of room. Stacked, three cards split the column equally and
\overrightarrow{AB} — taller than a bare name — was the only one left with a scrollbar. Alone in
its view, a card grows with its content instead of shrinking. The panel is sized for the fullest
view, so switching never changes the height of the block.

A block without a difference has no bar in its panel, which is still the common case.

How the cards are laid out

Up to four lines, the block grows to fit them. From the fifth on, the cards share the panel height
instead of making the block taller, so a long block gets small cards, each with its own scrollbar.
On a narrow block the whole panel moves behind an f(x) button over the plane, as in the other
blocks; it and the ⓘ are mutually exclusive, since they open over the same plane.


What this version deliberately does not do

obs-vector writes, draws and reports. It does not operate: nothing in it combines two of your
vectors into a third. None of the following is implemented, and nothing in the block hints that it
might be:

  • No operations between vectors. w = u + v is typeset, not resolved — not in the cards and
    not in the ⓘ.
  • No relation between three or more. The pair sections need exactly two vectors, or exactly two
    points; the block does not pick a pair out of five.
  • No arrow field for F(x,y). It is not one vector, it is infinitely many, and drawing a single
    one would misreport it.
  • No camera. No panning, no zoo...
Read more

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