@mathscan/geometry-engine
v0.1.2
Published
A React and TypeScript engine for creating, rendering, and validating interactive geometry exercises using Konva and JSXGraph
Readme
Geometry Engine
@mathscan/geometry-engine is a React + TypeScript library for authoring, rendering, and validating interactive geometry exercises. A teacher defines an exercise (given points, allowed constructions, instruments, and goals); a student constructs figures on an interactive canvas; the engine checks the construction against the goals and produces visual feedback.
- Konva / react-konva power the visible canvas, pointer interactions, and the draggable instrument overlays (ruler, set square, protractor).
- JSXGraph is the geometry kernel behind
src/measure/: projective intersections during answer validation and snapping. No board, no DOM. - Zod schemas define and validate the exercise and student-state data model, including cross-reference checks (duplicate ids, missing point references, inconsistent goals).
- Geometry Engine itself owns the exercise model, student state, pedagogical rules, tolerances, stores, and visual feedback.
Status
Version 0.1.0. Publication to the public npm registry is decided (ADR 0007) and has not happened yet; what a release promises and what has to be green before one is published are in docs/releasing.md, and what has changed is in CHANGELOG.md. The current feature set:
| Area | Supported today |
| ----------- | ----------------------------------------------------------------------------------------------------------------- |
| Objects | point, segment, line |
| Goals | create, throughPoints, perpendicular, parallel, segmentLength, midpoint (all tolerances configurable) |
| Instruments | ruler, setSquare, protractor — visual aids only: draggable, rotatable, no mathematical effect |
| Modes | Student solving (StudentGeometryCanvas) and teacher authoring (GeometryCanvas), both with undo/redo |
| Validation | Structural (Zod) and geometric (JSXGraph kernel) — both run in bare Node, no DOM |
| Feedback | Per-goal correct/incorrect status, matched/attempted construction highlighting, answer reveal |
Where this is going: FUTURE.md is the long-range feature map, docs/implementation/ROADMAP.md is the executable order, and docs/adr/ records the decisions already made.
Support matrix
"Verified" means something in the repository proves it on every run. Everything else is marked unverified — it is expected to work and nobody has checked.
| Area | Support | Status |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Node | ^20.19.0 \|\| >=22.12.0 (engines) | Verified in CI on 20.19.0, 22.12.0, and current LTS |
| React | ^19.0.0 peer, with react-dom ^19.0.0. /react requires React 19; React 18 is unsupported for now | Unverified below 19.2; React 19 is the only supported major (ADR 0007) |
| Vitest + jsdom | StudentGeometryExercise mounts and takes Pointer Events with two stubs — Test a host with Vitest and jsdom | Verified by pnpm run verify:package, which runs a Vitest + jsdom suite against the installed tarball. jsdom draws no pixels and hit-tests nothing |
| Konva | konva ^10.3.0 and react-konva ^19.2.5, both peers | Unverified against other majors |
| Build output | ES2020 target; five entry points, each as ESM + .d.ts and CJS + .d.cts | Verified by pnpm run verify:entries, which loads the built output in both modes |
| Package contents | dist, package.json, README.md — no tests, sources or dev page | Verified by pnpm run verify:package, which packs the tarball and consumes it in both module systems |
| Public API shape | Signatures and types snapshotted in etc/api-report.md | Verified by pnpm run verify:api on every run |
| Bundle size | Per-entry-point budgets in etc/bundle-size.json | Verified by pnpm run verify:size; measurements in docs/releasing.md |
| Browsers | Any browser meeting the ES2020 target with Canvas support | Chromium and WebKit verified in CI; no minimum version is claimed for either — the support floor is still unchosen |
| Mouse | Full: place, drag, connect, select | Verified in Chromium and WebKit by tests/e2e/ — place, snap, drag, connect, select, delete |
| Touch | One Pointer Events lifecycle for mouse, pen and touch since M3.H.4 — 8 px touch / 4 px mouse drag thresholds, full cancellation — with 44 CSS px activation targets since M3.H.3 | tests/e2e/touch.spec.ts and tests/e2e/gesture-cancellation.spec.ts on both engines — tap to place, finger-drag to connect and to build on bare canvas, tap to select, drag a given, threshold boundaries, every cancellation trigger, no tap-plus-click duplicate; targets and layout in tests/e2e/touch-targets.spec.ts and tests/dev-page/responsive-hardening.spec.ts across five emulated profiles; never exercised on a real device |
| Keyboard | The chrome, not the canvas: arm and cancel a tool, undo, redo, delete, check, reveal | Verified by tests/e2e/keyboard.spec.ts and tests/a11y/dev-page-keyboard.a11y.spec.ts. Placing a figure still needs a pointer — keyboard construction is a known gap |
| Screen readers | The semantic companion: the figure republished as DOM, announced through one polite live region | Verified by tests/a11y/dev-page-companion.a11y.spec.ts — current tool, pending connection, every entity as a sentence, the selection, and the last verdict |
| Structural validation | Bare Node, no DOM | Verified by the unit suite |
| Geometric validation | Bare Node, no DOM, no global state | Verified by tests/measure/serverGrading.test.ts, which runs with document undefined throughout |
Touch is still the entry most likely to surprise: emulated pointer events and emulated device profiles prove the routing and the measurements, not how the gestures feel in a hand. docs/accessibility.md § Pointer gestures states the gesture contract. Physical-device testing is deferred by decision, not done. The browser row is the other one — two engines are exercised, but no minimum version has been chosen for either, so this table says which engines are tested and not which releases are supported.
Installation
Publication status:
@mathscan/geometry-engineis a public npm package by decision (ADR 0007), and the first publication has not been made yet. Until it is, install the packed archive; the release checklist is in docs/releasing.md.
Once published:
pnpm add @mathscan/geometry-engine react react-dom konva react-konvaUntil then, build and pack the library:
pnpm install
pnpm build
pnpm packand install the generated .tgz archive in the consuming application:
pnpm add /path/to/mathscan-geometry-engine-0.1.0.tgzEither way the consuming application gets the same archive contents — dist, package.json and
README.md, nothing else — which pnpm run verify:package checks on every run.
react, react-dom, konva, and react-konva are peer dependencies. zod and jsxgraph are installed as runtime dependencies.
Entry points
The package has one root and four subpaths. Three of them load in a bare Node process — no React, no Konva, no DOM — which is what lets a grader import validation without a renderer:
| Import | Contains | Server-safe |
| -------------------------------------- | --------------------------------------------------------- | ----------- |
| @mathscan/geometry-engine | the union of the four below | no |
| @mathscan/geometry-engine/core | entity model, geometry, coordinates, snapping, the stores | yes |
| @mathscan/geometry-engine/schemas | the stored documents, their goals, and their migrations | yes |
| @mathscan/geometry-engine/validation | grading, feedback, answer reveal, the measurement seam | yes |
| @mathscan/geometry-engine/react | canvases, shapes, the toolbar, and the store hook | no |
Each resolves in both of Node's module systems: import gets ESM with .d.ts declarations, require gets CommonJS with .d.cts declarations.
docs/api-surface.md classifies every export as stable or advanced, and states what tree-shaking does and does not do for you. It is the semver promise, and tests/publicSurface.test.ts parses it, so it cannot drift from the code.
Architecture
For a plain-language overview, see How the Geometry Engine Works.
The source is organized into small, dependency-directed modules:
src/
├── index.ts Public API surface (everything is exported from here)
├── geometry/ Plain geometric types and math (Point, GeometryObject, distances, hit testing)
├── coordinates/ Screen <-> math coordinate transforms, grid steps, viewport line clipping
├── schemas/ Zod schemas: exercise, geometry state, objects, goals, viewport
│ └── goals/ One schema per goal kind (create, throughPoints, perpendicular, parallel, segmentLength, midpoint), plus each kind's authoring declaration
├── snapping/ Where a placed point lands (points, intersections, grid, objects)
├── state/ Resolves stored point origins into coordinates
├── store/ Framework-agnostic store primitive + teacher and student stores
├── validation/ Answer checking, exercise validation, feedback derivation
│ └── goals/ One handler per goal kind (accepts / satisfies / isRelevantAttempt / reveal)
├── measure/ The one JSXGraph boundary: kernel.ts (JXG) + geometryAdapter.ts
└── renderer/ React/Konva components: canvases, toolbar, grid, instruments
└── shapes/ Point, segment, and line Konva shapesKey design decisions, recorded in full under docs/adr/:
- Exercise vs. state separation.
GeometryExercise(the authored definition) andGeometryState(the student's live points and constructions) are distinct types.createStudentStore()copies the given point positions into a clean state, so student work never mutates the exercise (ADR 0001). - Framework-agnostic core.
createStore()is a minimal zustand-style store (getState/setState/subscribe) with no React dependency;useStoreState()bridges it into React. Geometry math, schemas, and structural validation all run in plain Node. - Pluggable goal kinds. Each goal type is a
GoalKindHandlerwith three responsibilities:satisfies()(does a candidate fulfil the goal — a candidate is a construction or a point the student constructed),isRelevantAttempt()(should a failed construction be highlighted as an attempt), andbuildRevealedAnswer()(synthesize a canonical answer shape). Adding a goal kind means adding one schema and one handler. - A single measurement seam.
src/measure/is the only module that knows JSXGraph exists; everything else asks for a named measurement and passes plain coordinates (ADR 0004, ADR 0005). Inside the seam the split is again one file deep:kernel.tsis the only file that imports JXG, andgeometryAdapter.tsexposes named measurements built on it. - No board, no global state. Trivial operations — distance, coincidence, the angle of a line — are plain arithmetic under the numerical policy; only projective meets go to JSXGraph, and those need no board. Grading is a pure function call that runs in bare Node (ADR 0006).
- Renderer-independent transforms.
coordinates/transform.tsmaps the math viewport (y-up) onto canvas pixels (y-down) withcontain(angle-preserving, default) orstretchprojections; both rendering and pointer conversion consume the same placement.
Core model
GeometryExercisedescribes the viewport, the authoredentities, how they are presented, which of them a student may drag, the allowed constructions, instruments, and goals.GeometryStateholds the student's ownentities— one record for every kind — plus theirpresentationandanswerEntityIds, the list of what counts as their answer.
import type { GeometryExercise, GeometryState } from '@mathscan/geometry-engine'Example exercise
This exercise asks the student to construct segment AB:
import type { GeometryExercise } from '@mathscan/geometry-engine'
const exercise: GeometryExercise = {
schemaVersion: 5,
id: 'segment-ab',
viewport: [0, 0, 10, 10],
showGrid: true,
// One record for every kind. v1's `givens[]` held four shapes with four
// vocabularies; a v2 entity is `{ kind, ... }` and a point's coordinates are
// its origin, not two loose fields.
entities: {
A: { kind: 'point', at: { x: 2, y: 3 } },
B: { kind: 'point', at: { x: 8, y: 3 } },
},
// How an entity is shown, kept out of what it *is*. A label and a role live
// here so that making a point visible never touches its definition.
presentation: {
A: { role: 'explicit', label: 'A' },
B: { role: 'explicit', label: 'B' },
},
// v1's per-given `draggable` flag. It is a list on the exercise and
// unrepresentable in an attempt, so a tampered attempt cannot declare a
// pinned given draggable and move what its own goals are measured against.
draggableEntityIds: [],
allowedObjects: ['segment'],
tools: ['ruler'],
goals: [
{
id: 'goal-ab',
type: 'throughPoints',
objectType: 'segment',
pointIds: ['A', 'B'],
},
],
}Supported objects are point, segment, line, circle, and angle. A circle is stored as
{ id, type: 'circle', center, through } — the id of its centre point and the id of a point it
passes through. Its radius is measured from those two on every evaluation and never stored, so
dragging either one changes the circle and everything built on it. There is no compass tool and no
circle goal kind yet: a circle can be authored as a given, persisted in an attempt, resolved,
intersected, snapped to and drawn, which is what M2.8 landed.
An angle is stored as { kind: 'angle', parents: [arm, vertex, arm] } — three point ids in the
order of ∠ABC. Its measure is derived on every evaluation as a counter-clockwise sweep in turns,
so reversing the arms gives exactly 1 − sweep, 0° and 180° are ordinary angles, and a reflex angle
needs no flag; only an arm of zero length makes it degenerate. The curved mark is the entity, drawn
at a radius taken from the shorter arm, and the arms a figure shows are ordinary segments over the
same points, so dragging a shared arm point moves the arm and the mark together. Nothing meets an
angle and nothing lies on one: a point defined on an angle, or where an angle crosses something,
is refused when the document is parsed. A presentation entry may carry measureLabel: 'degrees',
which only an angle may have. As with the circle there is no tool and no goal kind yet: an angle can
be authored as a given, persisted in an attempt, resolved, hit-tested, selected and drawn, which is
what M3.4 landed. Supported goal kinds are currently create, throughPoints, perpendicular, parallel, segmentLength, and midpoint.
A perpendicular or parallel goal names a reference object, a point the construction must pass through, and an angle tolerance in degrees. A segmentLength goal names a targetLength, and a midpoint goal names the reference segment; both take a toleranceUnits expressed in viewport units, not pixels. Because a parallel through a point that defines the reference is degenerate (redrawing the reference would be a correct answer), the exercise schema rejects that combination.
Hit testing
hitTest() answers "what is under the pointer" in math space, with no renderer involved:
import { deriveStudentRenderableGeometry, hitTest } from '@mathscan/geometry-engine'
// Coordinates are derived, never stored, so the scene comes from resolving the
// exercise and the attempt together. `RenderableGeometry` is already
// `{ points, objects }`, which is exactly what `hitTest()` takes.
const scene = deriveStudentRenderableGeometry(exercise, geometry)
const hits = hitTest({ x: 2, y: 1.05 }, scene, 0.3)
// [{ id: 'P1', type: 'point', distance: 0.05 }, { id: 'ab', type: 'segment', distance: 0.28 }]Results are ranked points first, then segments, then lines, and by distance within a kind — a small target that is harder to hit deliberately wins over a large one nearby. firstHit() returns the top result or null. Options are exclude (ids that can never be hit, such as the point a connection started from) and types (restrict the answer to certain kinds).
The tolerance is a math-space distance, deliberately independent of how thick the object is drawn: on touch devices the finger target must be much larger than the stroke. Convert a pixel radius through the viewport placement:
import {
CONNECTION_SNAP_RADIUS_PX,
computePlacement,
screenLengthToMath,
} from '@mathscan/geometry-engine'
const placement = computePlacement(exercise.viewport, canvas)
const tolerance = screenLengthToMath(CONNECTION_SNAP_RADIUS_PX, placement)distancePointToSegment() and distancePointToLine() are exported for direct use; both are pure math-space functions.
Numerical policy
Every epsilon that governs a geometric decision is named once, in src/numerics/policy.ts, and
exported. Nothing else in the engine writes a numeric literal as a tolerance. The module separates
three things that are easy to confuse:
| Kind | Examples | Who sets it |
| ------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------- |
| Calculation epsilon | CALCULATION_EPSILON, COORDINATE_EPSILON, PARAMETER_EPSILON, POINT_REUSE_EPSILON | the engine — a property of IEEE 754 |
| Grading tolerance | toleranceUnits, toleranceDegrees on a goal | the author, in the exercise |
| Interaction tolerance | PLACEMENT_SNAP_RADIUS_PX, CONNECTION_SNAP_RADIUS_PX | the engine, in pixels, converted per canvas |
Grading never consults a global tolerance: the only constant it may add to an authored tolerance is
GRADING_BOUNDARY_EPSILON, so an answer exactly on the boundary is not rejected by floating-point
noise. Degeneracy is measured relative to the size of the object it is asked about
(isDegenerateAtScale) rather than against a fixed size, so the same construction behaves the same
way whether it is drawn in thousandths or in thousands.
The policy is exercised by property-based tests (fast-check, in tests/numerics/) covering
distance as a metric, endpoint-order invariance, transform round trips, translation and rotation
invariance, on-object snaps lying on their target, intersections lying on both parents, and segment
parameters staying in [0, 1] — at three coordinate scales. Runs are seeded, so a failure prints a
replay seed; set FC_SEED to reproduce one and FC_RUNS to soak locally. Counterexamples the
properties have already found are pinned as ordinary tests in tests/numerics/regressions.test.ts.
Point origins and the dependency graph
A point in GeometryState stores why it is where it is, not a frozen pair of coordinates:
| Origin | Meaning |
| ---------------------------------- | ----------------------------------------------------------------------------------------------- |
| { x, y } | free — the point is simply there |
| { onObject: 'ab', t } | on an object, at parameter t along its two defining points (0 is the first, 1 the second) |
| { intersectionOf: ['ab', 'cd'] } | where two objects cross |
On a circle, t is a turn rather than a run between two points: 0 is the point at angle zero
from the centre, 0.25 a quarter turn counterclockwise, and 1 a full turn, so every value is on
the circle and t and t + 1 name the same place. It is measured from the positive x-axis and not
from the circle's own through-point, so that two definitions of the same circle put a point at t
in the same place.
Two objects can cross twice — a line and a circle, or two circles — so
{ intersectionOf: ['c1', 'c2'], which: 1 } selects the second crossing. Crossings are ordered by
coordinate rather than by whatever order the operands were given in, so a stored which still names
the same point after an author reverses a line's two points.
Coordinates are always derived, never stored alongside, so no cached copy can go stale:
import { resolvePoints } from '@mathscan/geometry-engine'
const points = resolvePoints(exercise, geometry) // Map<PointId, Point>Dragging a given point therefore moves everything built on it. A point that cannot be resolved — its object was deleted, its two objects became parallel so the crossing vanished, or its origins form a cycle — is simply absent from the map. Callers read that as "not on screen": renderers skip it, validation neither counts it as an answer nor reports it as an attempt. Nothing throws, because a student mid-construction is allowed to have a figure that briefly makes no sense.
An unresolved point is not discarded. Its origin stays in the document, so it comes back the moment its parents make sense again: drag two parallel objects back across each other and the intersection point returns, keeping its id and everything built on it. Only an explicit deletion removes anything, and that takes the dependent closure with it.
This is one-directional derivation (parents → children), not a constraint solver: nothing maintains a relationship while the student drags, which would solve the exercise for them (ADR 0002, ADR 0003).
Consequences elsewhere in the API:
addStudentPoint(store, origin)takes an origin.{ x, y }is still valid — that is a free point — while snapped placement passes{ onObject, t }or{ intersectionOf }.moveStudentPoint()respects what the point is: a free point takes the new coordinates, an on-object point slides along its object (the drag re-parameterizes rather than detaching it), and an intersection point ignores the drag entirely.isStudentPointDraggable()reports which.getStudentRemovalImpact()returns{ constructionIds, pointIds }and runs the cascade to a fixed point in both directions: deleting a segment can strip an intersection point built on it, which strips a construction that used that point, and so on. Given points are never removed.- Validation resolves before checking, so a goal is judged on where the figure actually is.
Save and restore an attempt
A GeometryState carries its own schemaVersion, so a stored attempt can be restored later — resume after a refresh, teacher review, analytics:
import {
createStudentStore,
serializeGeometryState,
validateGeometryStateJson,
} from '@mathscan/geometry-engine'
// Save
const saved = serializeGeometryState(store.getState().geometry)
// Restore, checked against the exercise it belongs to
const restored = validateGeometryStateJson(saved, exercise)
if (restored.success) {
const store = createStudentStore(exercise, restored.data)
}validateGeometryStateJson() is the counterpart to validateGeometryExerciseJson(): it parses the JSON, migrates the document to the current version, checks its structure, and — when given the exercise — checks it against that exercise (given points present, only allowed object types, point origins naming objects that exist). Every failure comes back as errors; nothing throws. A resumed store starts with empty undo history, since undo must not reach back past what the student can see.
Versions and migration
The two documents version independently. The exercise is at version 5 and the attempt is at version 2. A v2 attempt is loaded and graded against a v5 exercise, and neither version implies the other. Version 3 exists for one optional field, grading (see Strict grading below). Version 4 exists for one optional field, solution, the authored canonical attempt (see Reveal answers). Version 5 exists for the angle goals. An older exercise carrying a field added after its version is refused rather than promoted. An older reader rejects a newer exercise at its own version boundary rather than reading a field it does not understand.
Both stored documents carry a schemaVersion, and both are migrated the same way. An exercise migrates through geometryExerciseMigrations up to latestGeometryExerciseVersion; an attempt through geometryStateMigrations up to latestGeometryStateVersion. Each list holds one step per version hop, walked in chain order by applyMigrations(), so a document three versions old takes three steps and each step only has to know about its own neighbour.
When migration runs. On the way in, and only there: validateGeometryExercise(), validateGeometryExerciseJson(), validateGeometryStateInput() and validateGeometryStateJson() all migrate before they check structure, so an older stored document loads instead of failing as malformed. Nothing migrates on the way out.
Whether it mutates the input. No. applyMigrations() builds a new document and never writes to the one it was handed, so the object you pass in is the object you still have afterwards. It does not deep-copy either: parts of a migrated document may be the same objects as parts of the input, so treat the input as read-only once you have migrated it.
Failure is a message, never a throw. A step that trips over a shape it did not expect is caught, and every migration-stage refusal is reported as an ordinary validation error under the schemaVersion path, with a message naming the version and the field at fault — suitable to log as-is.
A newer document is refused, not downgraded. Guessing which fields to drop would silently destroy student work, so there is no backward migration and none is planned. A document whose schemaVersion is newer than the code loading it fails validation with a message naming both versions — a refusal you can log and act on, never a partial load.
Rollback and backups are the host's, and this is the only place they can be taken. The package migrates in memory and writes nothing: loading a v1 document does not change what is in your database. The old version stops existing at the next save, when a store that was seeded from a migrated document is serialized back over it. So the expectations are:
- Keep the bytes you were handed, before you migrate them. The raw JSON string as it came out of storage is the backup; take it there rather than from the parsed object, because
applyMigrations()does not deep-copy and a migrated document may share sub-objects with its input. - Write the backup before the first save of a migrated document, not before the load. A read-only session — grading, review, rendering a past attempt — migrates on the way in and never persists, so it needs no backup at all.
- Keep it for as long as your rollback window. Downgrading the package is only safe while a pre-migration copy of every document written by the newer one still exists; a v2 document handed back to a build that only knows v1 is refused, so a rollback without backups leaves those attempts unreadable rather than corrupted.
- Migrate once, then store the result. Re-migrating an already-current document is a no-op —
applyMigrations()walks zero steps — so a backfill that migrates and saves every document is safe to re-run, and turns the rollback window into a decision you make once instead of per student.
Restoring a backup is a plain overwrite: the older document loads and migrates again exactly as it did the first time, because migration is a pure function of the stored bytes.
Serialization always emits the current version. serializeGeometryState() writes the document it is given, and a document only reaches it through a loader or a store — both of which produce the current version. There is no way to ask the package for an older one.
Snapping
Both canvases snap a placed point instead of taking the raw pixel, so clicking near a landmark lands on exact coordinates. computeSnap() decides where:
import { computeSnap } from '@mathscan/geometry-engine'
const snap = computeSnap(
pointerInMathSpace,
{ points, objects, viewport: exercise.viewport, showGrid: exercise.showGrid },
{ tolerance },
)
// { position: { x: 2, y: 2 }, kind: 'intersection', targetIds: ['ab', 'cd'], distance: 0.14 }Four kinds, in priority order — the strongest kind with a candidate in range wins outright, so a nearby existing point is never passed over for a marginally closer grid crossing:
| Kind | Snaps to |
| -------------- | ----------------------------------------------------- |
| point | an existing point, given or constructed |
| intersection | where two objects cross, even with no point there yet |
| grid | a grid crossing, only when the grid is shown |
| onObject | the nearest place on a segment or line |
snapPosition() is the convenience form, returning the pointer unchanged when nothing is in range. exclude skips ids (the point being dragged), kinds restricts which rules apply. Intersections come from intersectionsOf() in the JSXGraph adapter — real geometry rather than coordinate arithmetic — but that call needs no validation board, which is what makes it affordable on every pointer move.
In the renderer, usePlacementSnap() tracks the pointer while the point tool is armed and resolves a click to its snapped position; SnapMarker draws where the point would land, since a silent snap reads as a bug.
originForSnap() turns a snapped placement into the origin to store — which is what makes snapping structural rather than cosmetic. Snapping to an intersection records { intersectionOf }, so the point is the crossing and follows it; snapping onto an object records { onObject, t }. It returns null when the pointer landed on an existing point, because stacking a second point there would be a duplicate rather than an answer. The student canvas also passes createPoint to useConnectionPreview, so a segment can end on an intersection that has no point yet: the point is created with that origin mid-gesture and immediately connected.
Validate exercise data
Use the safe validation facade for data loaded from JSON, an API, or another untrusted source:
import { validateGeometryExerciseJson } from '@mathscan/geometry-engine'
const validation = validateGeometryExerciseJson(json)
if (!validation.success) {
console.error(validation.errors)
} else {
console.log(validation.data)
console.log(validation.warnings)
}validateGeometryExercise(input)validates an unknown value without throwing.validateGeometryExerciseJson(json)parses and validates a JSON string.parseGeometryExercise(input)returns a typed exercise or throws a Zod error.getGeometryExerciseWarnings(exercise)reports pedagogical issues that are legal but suspicious.- Unknown properties, duplicate identifiers, duplicate geometry, missing references, and inconsistent goals are rejected by the schema's cross-reference checks.
- Entity ids:
__proto__is the only name refused. Every other id is legal,constructor,toString,valueOfandhasOwnPropertyincluded — every record read is an own-property read, so an id that happens to name a JavaScript built-in resolves, renders and grades like any other.__proto__is refused, at parse for a v2 document and during migration for a v1 one, because no JavaScript object can carry it as an entry: a stored document naming it now fails to load with a message and a path, where it previously loaded one entity lighter and said nothing. If you read anentitiesorpresentationrecord yourself, use the exportedentryOf(record, id)rather thanrecord[id]— a bare index answers forconstructorwith a function off the prototype chain.
Structural validation works without a canvas and can run in Node.js.
Embed a student exercise
StudentGeometryExercise is the whole student side in one component: tools, Undo, Redo, Delete,
Reset, the construction notice, a canvas that fills its container's width and follows it as it
resizes, and the semantic companion. Your application keeps Check, Reveal, saving and the wording of
results.
import { StudentGeometryExercise } from '@mathscan/geometry-engine/react'
import type { GeometryState } from '@mathscan/geometry-engine/schemas'
import { checkGeometryAnswer } from '@mathscan/geometry-engine/validation'
import { useState } from 'react'
function Exercise({ exercise, saved }: { exercise: unknown; saved?: GeometryState }) {
const [attempt, setAttempt] = useState(saved)
const [checked, setChecked] = useState<ReturnType<typeof checkGeometryAnswer> | null>(null)
return (
<>
<StudentGeometryExercise
exercise={exercise}
value={attempt}
// Hand the attempt straight back: an echo keeps Undo. `meta.action` says
// what the student did, for a sound or an effect.
onChange={(next) => setAttempt(next)}
validationFeedback={checked}
/>
<button onClick={() => setChecked(checkGeometryAnswer(exercise, attempt))}>Check</button>
</>
)
}- Controlled.
exerciseandvalueare untrusted and parsed on every render. Both are compared by content, so a copy or a JSON round trip of the attempt you were given is recognized as the same value, and an object you edit in place is seen. A different value replaces the attempt, and a different exercise starts a new session. Neither callsonChange, and both clear Undo and cancel a gesture in progress. A change to the exercise'ssolutionalone is not a new exercise. An absentvalue(undefinedornull) is a fresh attempt. - A new value is one that differs from the last one you passed. Passing back the attempt
onChangegave you is how the component stays in step. A host that never does cannot reset by passing the same empty attempt again: use Reset, or pass a changed value. onChangefires once per completed student action, with{ action, entityIds }, and never for drag frames. It receives a copy, so editing it does not touch the component. A refused duplicate is not a change: it is shown, and reported toonNotice.validationFeedbacktakes the wholecheckGeometryAnswer()result. It is shown only while the exercise and attempt still match what was checked, so an edit hides it immediately.readOnlylocks the attempt in the store, not only in the controls; replacingvaluestill works.controls={{ toolbar, history, delete, reset }}hides built-in controls you replace with your own.showCompanion={false}removes the semantic companion: keyboard selection, the figure's screen-reader description, its live announcements and the forced-colours fallback. The canvas, the controls and the construction notice are unaffected. It defaults totrue.- Invalid input shows an alert, calls
onErroronce per distinct list of issues, and leaves the current session untouched. The built-in wording is English. - Every mounted component has its own store. Size it with CSS on
className; the canvas takes the full width at the exercise viewport's aspect ratio.
Test a host with Vitest and jsdom
jsdom has no canvas backend and no layout, and the component needs both: Konva draws through
getContext('2d'), which jsdom answers with null, and the canvas is sized by a ResizeObserver,
which jsdom does not implement. Add a setup file with two stubs:
// vitest.config.js: test: { environment: 'jsdom', setupFiles: ['./vitest.setup.js'] }
HTMLCanvasElement.prototype.getContext = function getContext() {
const context = {
canvas: this,
measureText: () => ({ width: 0 }),
getImageData: () => ({ data: [] }),
createLinearGradient: () => ({ addColorStop() {} }),
createRadialGradient: () => ({ addColorStop() {} }),
createPattern: () => null,
}
// Anything else Konva calls becomes a no-op the first time it is read.
return new Proxy(context, {
get: (target, property) =>
property in target || typeof property !== 'string'
? target[property]
: (target[property] = () => {}),
})
}
globalThis.ResizeObserver = class ResizeObserver {
constructor(callback) {
this.callback = callback
}
observe(target) {
this.callback([{ target, contentRect: { width: 400, height: 300 } }], this)
}
unobserve() {}
disconnect() {}
}
globalThis.IS_REACT_ACT_ENVIRONMENT = trueWith it, the component mounts, its buttons work, and Pointer Events dispatched on its
.konvajs-content element reach the gesture lifecycle; jsdom lays the stage out at the origin, so
clientX and clientY are stage pixels. Two limits: nothing is drawn, and Konva's hit canvas finds
nothing, so every press lands on bare canvas — a press cannot select or drag a drawn point. Test
those in a real browser. tests/consumer/ runs exactly this setup against the packed package on
every pnpm run check.
Run these tests with NODE_ENV unset or test, never production. Under production, Vite
resolves the jsdom environment like a browser build, so node: imports fail to load, and React's
production build has no act().
Render a student exercise
For a composition of your own, create one student store per attempt and pass it to the student canvas:
import {
createStudentStore,
selectStudentObjectType,
StudentGeometryCanvas,
Toolbar,
useStoreState,
type GeometryExercise,
} from '@mathscan/geometry-engine'
import { useState } from 'react'
function StudentExercise({ exercise }: { exercise: GeometryExercise }) {
const [store] = useState(() => createStudentStore(exercise))
const state = useStoreState(store)
return (
<>
<Toolbar
selected={state.pendingObjectType}
available={exercise.allowedObjects}
onSelect={(type) => selectStudentObjectType(store, type)}
/>
<StudentGeometryCanvas store={store} size={{ width: 720, height: 540 }} />
</>
)
}createStudentStore() starts a clean GeometryState; the student's work stays separate from the exercise definition. Store actions include addStudentPoint, moveStudentPoint, pickStudentPoint (two picks connect a segment or line), promoteStudentPoint, and selectStudentObjectType.
One gesture, one command
executeConstruction(store, { tool, from, to }) is the single path from a gesture to geometry, and each end of it is either a point that already exists or an origin to build one from:
executeConstruction(store, {
tool: 'segment',
from: { kind: 'existing', id: 'A' },
to: { kind: 'create', origin: { kind: 'point', intersectionOf: ['ab', 'cd'] } },
})Three rules follow from that, and all three are visible to a host (RFC 0001 § 11):
- One gesture is one commit, so one undo. Every entity the command needs is written together. A segment that had to invent its endpoint still costs the student a single undo, and undoing it takes the invented point with it.
- An invented endpoint is not the answer. It is written
role: 'implicit'and left out ofanswerEntityIds, so acreategoal is never satisfied by scaffolding the student did not choose to make. It is still a real graph node and still a snap target, so the next shape drawn from there shares the vertex rather than stacking a second point on it. - Endpoints are the engine's problem. A segment or line can be drawn on an empty canvas; nothing has to be placed first.
promoteStudentPoint()later makes a hidden endpoint visible — one field,role: 'explicit', with the point's id, origin and candidacy untouched.
A construction that is already there is not built twice (RFC 0004 § Decision 1). Sameness is the entity kind's own identityKey — the comparison the schema's duplicate check uses — so drawing B→A repeats A→B, a line on the same points is a different construction, and two points that only happen to coincide are two points. A student's work is compared with the student's own work, never with the givens. A refused command changes nothing, costs no undo and keeps the redo stack; instead state.constructionNotice becomes { code: 'duplicate-construction', entityId }, naming what is already there, for the host to say in its own words. The next creation attempt, a tool change, an undo or a redo clears it. Standalone point additions return the existing point's id in the same case.
executeGivenConstruction() is the authoring equivalent on the teacher store, with one deliberate difference: a teacher endpoint that snaps to a crossing is stored as a free point at that position, because authoring derived givens is not yet an editor affordance.
Undo, redo, and deletion
Every change to the student's geometry is a history entry: undoStudentChange() and redoStudentChange() walk past/future snapshots of GeometryState, and any new work clears the redo stack. Consecutive moves of the same point collapse into one entry so a drag costs one undo instead of one per pointer event; a renderer calls endStudentPointDrag() on drag end to close that window (StudentGeometryCanvas already does, and does so too when a drag is cancelled — the point keeps its last position and one undo takes it back).
History is capped at 100 entries, in both stores. A snapshot is structurally shared — the entity definitions inside it are the same objects the live document holds — so an entry costs one record slot per entity rather than a deep copy, but it is not free and nothing used to release one. The 101st edit drops the oldest, so undo depth is finite. Why snapshots rather than inverse commands, and why the cap, are ADR 0009 § Q3.
resetStudentAttempt(store) starts the attempt over (RFC 0004 § Decision 2). The exercise and its givens stay; the student's constructions go, a given they dragged returns to where the teacher put it, and the tool, the selection, a pending endpoint and the construction notice are cleared. A resumed attempt resets to empty, not to what was saved. It is not undoable — both history stacks are emptied, so neither Undo nor Redo brings the old attempt back — and it keeps the store object, so the canvas and every other subscriber stay wired. It never touches a saved copy: saving is the host's, and so is deleting one. The interaction epoch moves even on an attempt that was already empty, which is how a mounted canvas drops a gesture in flight. The host rule: checked feedback describes the geometry it was made on, so clear it — and whatever goal it focused — whenever geometry or interactionEpoch changes; that is what clears a stale check after a reset with nothing to reset. Replacing the store, the older way to restart, still works, but it drops every subscription with it.
selectStudentObject(store, id) selects a construction or a student point — StudentGeometryCanvas wires this to taps and draws the selection — and deleteStudentObject(store, id) removes it. Deleting a point also removes every construction built on it; getStudentRemovalImpact(state, id) reports that cascade first, so the application can confirm before deleting. Given points and given objects belong to the exercise and are never deletable (isStudentOwned(state, id) is the test).
The exercise's tools render as draggable, rotatable instrument overlays (GeometryInstruments) on top of the canvas.
Instruments are visual aids with no mathematical effect. The ruler, set square, and protractor
are pictures the student can move and rotate. They constrain nothing, measure nothing, snap to
nothing, and are invisible to validation — listing a tool in tools decorates the canvas, it does
not require the tool's use. No goal can express "construct this with the compass", and no check can
tell whether an instrument was touched. Functional instruments, including the missing compass, are
deferred beyond the focused Milestone 3.
Check a student's answer
checkGeometryAnswer() validates both inputs before evaluating the goals:
import { checkGeometryAnswer } from '@mathscan/geometry-engine'
const checked = checkGeometryAnswer(exercise, store.getState().geometry)
if (!checked.success) {
console.error(checked.errors)
} else {
console.log(checked.data.result.status) // "correct" or "incorrect"
console.log(checked.data.goals) // matches used by visual feedback
}Why an answer is wrong
checkGeometryAnswerDetailed() takes the same untrusted inputs and reports the reasoning behind the
verdict, as stable codes rather than prose:
import { checkGeometryAnswerDetailed } from '@mathscan/geometry-engine/validation'
const checked = checkGeometryAnswerDetailed(exerciseJson, attemptJson)
if (!checked.success) {
// 'invalid-exercise' or 'invalid-attempt' — a malformed document, not a wrong answer.
console.error(checked.errors.map((issue) => issue.code))
} else {
for (const goal of checked.data.goals) {
// 'goal-satisfied' | 'goal-not-satisfied' | 'unresolved-candidate' | 'no-candidate',
// with the entity ids behind each: matched, relevant, unresolved.
console.log(goal.id, goal.code, goal.matchedEntityIds)
}
// Whole-report notes: 'no-goals', 'unresolved-entity', 'extra-objects'.
console.log(checked.data.diagnostics)
}checked.data.feedback is exactly what checkGeometryAnswer() returns, so a host can adopt the
report without changing what it already draws. The codes are language-neutral by design: the wording
shown to a student belongs to the application. evaluateGeometryStateDetailed(exercise, geometry) is
the same report for already-typed documents. Numerical deviations ("out by 3°") are not part of this
vocabulary.
Starting an attempt and knowing whether it has work
Both check functions also return fingerprints on success: opaque strings describing the normalized
exercise and attempt that were graded. Keep the whole result together; the engine uses them to hide
feedback once the student's attempt no longer matches it. Do not parse or compare them yourself.
import { createEmptyGeometryAttempt, hasGeometryWork } from '@mathscan/geometry-engine/validation'
const attempt = savedAttemptJson ?? createEmptyGeometryAttempt() // a fresh object every call
// A "not started" hint, never a grade: true once the attempt holds any student entity,
// including a moved given; false for empty, reset or malformed input.
const started = hasGeometryWork(attempt)hasGeometryWork() reads no exercise, so a stored version-1 attempt that still re-declares pinned
givens counts as work.
Strict grading
By default an answer object no goal needed costs nothing: the attempt is correct if every goal is. An exercise can ask for the opposite:
import { setExtraObjectPolicy } from '@mathscan/geometry-engine'
setExtraObjectPolicy(teacherStore, 'reject') // stores grading: { extraObjects: 'reject' }
setExtraObjectPolicy(teacherStore, 'allow') // removes the field againThe field is optional and absence means allow, so an exercise authored before this existed
grades as it always did. Under reject the engine starts from the ids in answerEntityIds that the
attempt owns, subtracts every entity that satisfies some goal and, transitively, everything those
entities are built on, and reports what is left as extraEntityIds with an extra-objects
diagnostic. A non-empty list makes the verdict incorrect while each goal keeps its own status —
"the goals are met and something unused is still on the canvas" is a distinct outcome, and a host
should say so rather than blame a goal.
What is never an extra: a given, an engine-invented endpoint, an implicit or promoted entity outside
answerEntityIds, and any entity a satisfied answer is built on, however deep and however the
student created it. What is: an entity the attempt names as an answer and nothing needed —
including one that no longer resolves. The policy does not count answers, so two correct segments
for one goal are both matches. Reveal adds the canonical answer and deletes nothing, so a strict
recheck after a reveal stays incorrect until the extra object is removed.
extraEntityIds is reported under both policies; only reject files the diagnostic and changes the
verdict.
The lower-level APIs are also available:
validateGeometryStateInput(geometry, exercise)validates student data only.validateGeometryState(exercise, geometry)evaluates already-typed data and returns the plain result.evaluateGeometryState(exercise, geometry)also returns construction and point matches for visual feedback.deriveGeometryCanvasFeedback(feedback, geometry, focusedGoalId)converts evaluation output for a custom renderer.
Geometric validation needs no DOM
Geometry Engine selects candidate constructions and applies the exercise rules and configured tolerances (for example, toleranceDegrees on a perpendicular or parallel goal). Distance and angle comparisons are plain arithmetic under the numerical policy; intersections go to the JSXGraph kernel, which needs no board and therefore no container element.
Validation — structural and geometric — runs in a bare Node process. No DOM, no jsdom, no global state, and no rule about import order. Grading is a pure function call: it can be interleaved, called concurrently, and needs nothing freed afterwards. Earlier development builds ran geometric checks on a hidden JSXGraph board and needed jsdom on the server; that board is gone — see ADR 0006.
Grading on a server
import { checkGeometryAnswer } from '@mathscan/geometry-engine/validation'
export function gradeAttempt(exerciseJson: unknown, attemptJson: unknown) {
const checked = checkGeometryAnswer(exerciseJson, attemptJson)
if (!checked.success) return { ok: false as const, errors: checked.errors }
return { ok: true as const, result: checked.data.result }
}That is the whole setup. Both arguments are untrusted JSON: checkGeometryAnswer() validates structure before any geometry runs, so malformed input comes back as errors rather than throwing. Importing /validation rather than the root is what keeps React and Konva out of the server build; scripts/verify-entry-points.mjs loads the built entry point in a bare Node process and fails if either becomes reachable. tests/measure/serverGrading.test.ts runs this exact path on every test run, asserting that document is undefined before the import, after it, and after grading.
Display validation feedback
Pass the output of evaluateGeometryState() or the successful checkGeometryAnswer() result back to the student canvas:
const checked = checkGeometryAnswer(exercise, store.getState().geometry)
const feedback = checked.success ? checked.data : null
<StudentGeometryCanvas
store={store}
size={{ width: 720, height: 540 }}
validationFeedback={feedback}
focusedGoalId="goal-ab"
/>Matching constructions are displayed as correct. Relevant unsuccessful attempts can be highlighted as incorrect.
The verdict never rides on colour alone: correct work is drawn solid and incorrect work dashed, and every point the check touched gains a ring around it, so the three states — unchecked, correct, incorrect — differ in shape as well as in colour.
Teacher authoring
Teacher mode edits the GeometryExercise itself:
import { createTeacherStore, GeometryCanvas } from '@mathscan/geometry-engine'
const teacherStore = createTeacherStore(exercise)
<GeometryCanvas store={teacherStore} size={{ width: 720, height: 540 }} />The teacher store exposes actions for points, segments, lines, goals, tools, selection, removal, and undo/redo (undoTeacherChange / redoTeacherChange, backed by past/future exercise snapshots). Point names are generated in spreadsheet order (A, B, …, Z, AA, …). Removing a given reports its impact first (GivenRemovalImpact: dependent objects and goals), so an application can confirm cascading deletions. GeometryCanvas handles the figure; an application can build its own surrounding authoring interface with these actions.
Reveal answers
An exercise at version 4 or later can embed its authored canonical answer as solution, an ordinary attempt document. revealGeometryAttempt() is synchronous, needs no DOM and accepts untrusted JSON. It returns that attempt only when the solution is valid and correct for the exercise, so the host can replace the student's value with it:
import { revealGeometryAttempt } from '@mathscan/geometry-engine/validation'
const revealed = revealGeometryAttempt(exerciseJson)
if (revealed.success) {
saveAttempt(revealed.data) // replaces the student's attempt; nothing is merged
} else {
// 'invalid-exercise' | 'invalid-canonical-attempt' | 'canonical-attempt-incorrect'
console.warn(revealed.errors.map((error) => error.code))
}A missing solution and a malformed one are both invalid-canonical-attempt. A malformed solution's error paths start at solution. A solution that is valid but fails the exercise's goals, or its strict extra-object policy, is canonical-attempt-incorrect. The solution is client-visible and is not a secret. Checking ignores it: a malformed solution never fails a check, and changing the solution changes neither the result nor its fingerprints. A student store is always given the exercise without it. validateGeometryExercise() validates the whole document instead, so an authoring tool learns about a broken solution when it saves.
The older, goal-synthesized path stays available for advanced callers. It generates one possible answer per goal rather than reading an authored one:
import { revealGeometryAnswers, revealStudentAnswer } from '@mathscan/geometry-engine'
const answers = revealGeometryAnswers(exercise)
const answer = answers.find((item) => item.id === 'goal-ab')
if (answer?.shape) {
revealStudentAnswer(store, answer.shape)
}Each goal handler can synthesize a canonical answer shape — a segment, a line, or a point. For example, the perpendicular handler builds a perpendicular through the goal point and picks the direction that stays most visible inside the viewport (the parallel handler does the same along the reference direction), and the midpoint handler returns the midpoint itself. Revealing a point answer adds it to the student's constructed points, so re-checking the attempt passes. Some goals, such as a generic create goal, have no single canonical shape to reveal and therefore return shape: null.
Development
From a clean clone:
pnpm install --frozen-lockfile # exactly what CI installs
pnpm check # the full gate: typecheck, lint, test, format, build, verifypnpm install (without the flag) is fine for day-to-day work; the frozen form is
what a fresh clone and CI use, and it fails rather than silently updating
pnpm-lock.yaml. pnpm itself is pinned by the packageManager field, so
corepack enable is enough to get the right version.
Launch the playground:
pnpm dev # dev-page playground with teacher editor + student preview
pnpm playground # same, opens the browser automaticallyThe dev-page/ app is a full demo: a teacher authoring surface, a live student preview, exercise JSON import/export, and answer checking. It is not part of the published package.
Run the complete pre-publication verification (also enforced by prepublishOnly):
pnpm checkIt ends with the four package verifiers — the export map, the API report, the bundle budgets, and a consumer installed f
