@mathscan/geometry-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1101 -0
- package/dist/coordinates/grid.d.cts +8 -0
- package/dist/coordinates/grid.d.ts +9 -0
- package/dist/coordinates/grid.d.ts.map +1 -0
- package/dist/coordinates/lineClipping.d.cts +3 -0
- package/dist/coordinates/lineClipping.d.ts +4 -0
- package/dist/coordinates/lineClipping.d.ts.map +1 -0
- package/dist/coordinates/transform.d.cts +18 -0
- package/dist/coordinates/transform.d.ts +19 -0
- package/dist/coordinates/transform.d.ts.map +1 -0
- package/dist/core.cjs +1 -0
- package/dist/core.mjs +4 -0
- package/dist/entries/core.d.cts +16 -0
- package/dist/entries/core.d.ts +17 -0
- package/dist/entries/core.d.ts.map +1 -0
- package/dist/entries/react.d.cts +18 -0
- package/dist/entries/react.d.ts +19 -0
- package/dist/entries/react.d.ts.map +1 -0
- package/dist/entries/schemas.d.cts +14 -0
- package/dist/entries/schemas.d.ts +15 -0
- package/dist/entries/schemas.d.ts.map +1 -0
- package/dist/entries/validation.d.cts +12 -0
- package/dist/entries/validation.d.ts +13 -0
- package/dist/entries/validation.d.ts.map +1 -0
- package/dist/exercise.schema-BV2b7rtN.cjs +2 -0
- package/dist/exercise.schema-BV2b7rtN.cjs.map +1 -0
- package/dist/exercise.schema-ckVBW9XA.js +212 -0
- package/dist/exercise.schema-ckVBW9XA.js.map +1 -0
- package/dist/geometry/distance.d.cts +9 -0
- package/dist/geometry/distance.d.ts +10 -0
- package/dist/geometry/distance.d.ts.map +1 -0
- package/dist/geometry/entities/circle.d.cts +36 -0
- package/dist/geometry/entities/circle.d.ts +37 -0
- package/dist/geometry/entities/circle.d.ts.map +1 -0
- package/dist/geometry/entities/index.d.cts +60 -0
- package/dist/geometry/entities/index.d.ts +61 -0
- package/dist/geometry/entities/index.d.ts.map +1 -0
- package/dist/geometry/entities/line.d.cts +43 -0
- package/dist/geometry/entities/line.d.ts +44 -0
- package/dist/geometry/entities/line.d.ts.map +1 -0
- package/dist/geometry/entities/point.d.cts +27 -0
- package/dist/geometry/entities/point.d.ts +28 -0
- package/dist/geometry/entities/point.d.ts.map +1 -0
- package/dist/geometry/entities/relations.d.cts +27 -0
- package/dist/geometry/entities/relations.d.ts +28 -0
- package/dist/geometry/entities/relations.d.ts.map +1 -0
- package/dist/geometry/entities/segment.d.cts +45 -0
- package/dist/geometry/entities/segment.d.ts +46 -0
- package/dist/geometry/entities/segment.d.ts.map +1 -0
- package/dist/geometry/entityCapabilities.d.cts +2 -0
- package/dist/geometry/entityCapabilities.d.ts +3 -0
- package/dist/geometry/entityCapabilities.d.ts.map +1 -0
- package/dist/geometry/entityModel.d.cts +139 -0
- package/dist/geometry/entityModel.d.ts +140 -0
- package/dist/geometry/entityModel.d.ts.map +1 -0
- package/dist/geometry/entityRecord.d.cts +27 -0
- package/dist/geometry/entityRecord.d.ts +28 -0
- package/dist/geometry/entityRecord.d.ts.map +1 -0
- package/dist/geometry/evaluateGraph.d.cts +37 -0
- package/dist/geometry/evaluateGraph.d.ts +38 -0
- package/dist/geometry/evaluateGraph.d.ts.map +1 -0
- package/dist/geometry/geometryObject.d.cts +4 -0
- package/dist/geometry/geometryObject.d.ts +5 -0
- package/dist/geometry/geometryObject.d.ts.map +1 -0
- package/dist/geometry/goalCandidate.d.cts +8 -0
- package/dist/geometry/goalCandidate.d.ts +9 -0
- package/dist/geometry/goalCandidate.d.ts.map +1 -0
- package/dist/geometry/hitTest.d.cts +23 -0
- package/dist/geometry/hitTest.d.ts +24 -0
- package/dist/geometry/hitTest.d.ts.map +1 -0
- package/dist/geometry/point.d.cts +5 -0
- package/dist/geometry/point.d.ts +6 -0
- package/dist/geometry/point.d.ts.map +1 -0
- package/dist/geometry/pointOrigin.d.cts +5 -0
- package/dist/geometry/pointOrigin.d.ts +6 -0
- package/dist/geometry/pointOrigin.d.ts.map +1 -0
- package/dist/geometry/v1Bridge.d.cts +41 -0
- package/dist/geometry/v1Bridge.d.ts +42 -0
- package/dist/geometry/v1Bridge.d.ts.map +1 -0
- package/dist/geometryAttemptStatus-5L_q6-9l.js +184 -0
- package/dist/geometryAttemptStatus-5L_q6-9l.js.map +1 -0
- package/dist/geometryAttemptStatus-N_XHIkiS.cjs +2 -0
- package/dist/geometryAttemptStatus-N_XHIkiS.cjs.map +1 -0
- package/dist/geometryState.schema-D0ixyGYE.js +955 -0
- package/dist/geometryState.schema-D0ixyGYE.js.map +1 -0
- package/dist/geometryState.schema-DKQ-durx.cjs +2 -0
- package/dist/geometryState.schema-DKQ-durx.cjs.map +1 -0
- package/dist/goals-C3ZwbEh_.js +451 -0
- package/dist/goals-C3ZwbEh_.js.map +1 -0
- package/dist/goals-CxR5qPDC.cjs +2 -0
- package/dist/goals-CxR5qPDC.cjs.map +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +10 -0
- package/dist/measure/geometryAdapter.d.cts +26 -0
- package/dist/measure/geometryAdapter.d.ts +27 -0
- package/dist/measure/geometryAdapter.d.ts.map +1 -0
- package/dist/measure/kernel.d.cts +25 -0
- package/dist/measure/kernel.d.ts +26 -0
- package/dist/measure/kernel.d.ts.map +1 -0
- package/dist/numerics/policy.d.cts +17 -0
- package/dist/numerics/policy.d.ts +18 -0
- package/dist/numerics/policy.d.ts.map +1 -0
- package/dist/react-BEcdx1f6.js +1851 -0
- package/dist/react-BEcdx1f6.js.map +1 -0
- package/dist/react-Pp_VSk--.cjs +2 -0
- package/dist/react-Pp_VSk--.cjs.map +1 -0
- package/dist/react.cjs +1 -0
- package/dist/react.mjs +2 -0
- package/dist/renderer/GeometryCanvas.d.cts +14 -0
- package/dist/renderer/GeometryCanvas.d.ts +15 -0
- package/dist/renderer/GeometryCanvas.d.ts.map +1 -0
- package/dist/renderer/GeometryCompanion.d.cts +29 -0
- package/dist/renderer/GeometryCompanion.d.ts +30 -0
- package/dist/renderer/GeometryCompanion.d.ts.map +1 -0
- package/dist/renderer/GeometryGrid.d.cts +7 -0
- package/dist/renderer/GeometryGrid.d.ts +8 -0
- package/dist/renderer/GeometryGrid.d.ts.map +1 -0
- package/dist/renderer/GeometryInstruments.d.cts +7 -0
- package/dist/renderer/GeometryInstruments.d.ts +8 -0
- package/dist/renderer/GeometryInstruments.d.ts.map +1 -0
- package/dist/renderer/StudentGeometryCanvas.d.cts +11 -0
- package/dist/renderer/StudentGeometryCanvas.d.ts +12 -0
- package/dist/renderer/StudentGeometryCanvas.d.ts.map +1 -0
- package/dist/renderer/StudentGeometryExercise.d.cts +36 -0
- package/dist/renderer/StudentGeometryExercise.d.ts +37 -0
- package/dist/renderer/StudentGeometryExercise.d.ts.map +1 -0
- package/dist/renderer/Toolbar.d.cts +7 -0
- package/dist/renderer/Toolbar.d.ts +8 -0
- package/dist/renderer/Toolbar.d.ts.map +1 -0
- package/dist/renderer/deriveRenderableGeometry.d.cts +15 -0
- package/dist/renderer/deriveRenderableGeometry.d.ts +16 -0
- package/dist/renderer/deriveRenderableGeometry.d.ts.map +1 -0
- package/dist/renderer/deriveSemanticScene.d.cts +58 -0
- package/dist/renderer/deriveSemanticScene.d.ts +59 -0
- package/dist/renderer/deriveSemanticScene.d.ts.map +1 -0
- package/dist/renderer/entityDisplayName.d.cts +12 -0
- package/dist/renderer/entityDisplayName.d.ts +13 -0
- package/dist/renderer/entityDisplayName.d.ts.map +1 -0
- package/dist/renderer/gesturePolicy.d.cts +97 -0
- package/dist/renderer/gesturePolicy.d.ts +98 -0
- package/dist/renderer/gesturePolicy.d.ts.map +1 -0
- package/dist/renderer/shapes/CircleShape.d.cts +17 -0
- package/dist/renderer/shapes/CircleShape.d.ts +18 -0
- package/dist/renderer/shapes/CircleShape.d.ts.map +1 -0
- package/dist/renderer/shapes/EntityLabel.d.cts +14 -0
- package/dist/renderer/shapes/EntityLabel.d.ts +15 -0
- package/dist/renderer/shapes/EntityLabel.d.ts.map +1 -0
- package/dist/renderer/shapes/EntityShape.d.cts +18 -0
- package/dist/renderer/shapes/EntityShape.d.ts +19 -0
- package/dist/renderer/shapes/EntityShape.d.ts.map +1 -0
- package/dist/renderer/shapes/LineShape.d.cts +16 -0
- package/dist/renderer/shapes/LineShape.d.ts +17 -0
- package/dist/renderer/shapes/LineShape.d.ts.map +1 -0
- package/dist/renderer/shapes/PointShape.d.cts +17 -0
- package/dist/renderer/shapes/PointShape.d.ts +18 -0
- package/dist/renderer/shapes/PointShape.d.ts.map +1 -0
- package/dist/renderer/shapes/SegmentShape.d.cts +17 -0
- package/dist/renderer/shapes/SegmentShape.d.ts +18 -0
- package/dist/renderer/shapes/SegmentShape.d.ts.map +1 -0
- package/dist/renderer/shapes/SnapMarker.d.cts +8 -0
- package/dist/renderer/shapes/SnapMarker.d.ts +9 -0
- package/dist/renderer/shapes/SnapMarker.d.ts.map +1 -0
- package/dist/renderer/shapes/StrokedLine.d.cts +18 -0
- package/dist/renderer/shapes/StrokedLine.d.ts +19 -0
- package/dist/renderer/shapes/StrokedLine.d.ts.map +1 -0
- package/dist/renderer/studentExerciseSession.d.cts +71 -0
- package/dist/renderer/studentExerciseSession.d.ts +72 -0
- package/dist/renderer/studentExerciseSession.d.ts.map +1 -0
- package/dist/renderer/useConnectionPreview.d.cts +44 -0
- package/dist/renderer/useConnectionPreview.d.ts +45 -0
- package/dist/renderer/useConnectionPreview.d.ts.map +1 -0
- package/dist/renderer/usePlacementSnap.d.cts +28 -0
- package/dist/renderer/usePlacementSnap.d.ts +29 -0
- package/dist/renderer/usePlacementSnap.d.ts.map +1 -0
- package/dist/renderer/usePointerGestures.d.cts +46 -0
- package/dist/renderer/usePointerGestures.d.ts +47 -0
- package/dist/renderer/usePointerGestures.d.ts.map +1 -0
- package/dist/schemas/circle.schema.d.cts +8 -0
- package/dist/schemas/circle.schema.d.ts +9 -0
- package/dist/schemas/circle.schema.d.ts.map +1 -0
- package/dist/schemas/entityDefinition.schema.d.cts +69 -0
- package/dist/schemas/entityDefinition.schema.d.ts +70 -0
- package/dist/schemas/entityDefinition.schema.d.ts.map +1 -0
- package/dist/schemas/entityDocument.d.cts +95 -0
- package/dist/schemas/entityDocument.d.ts +96 -0
- package/dist/schemas/entityDocument.d.ts.map +1 -0
- package/dist/schemas/entityPresentation.schema.d.cts +12 -0
- package/dist/schemas/entityPresentation.schema.d.ts +13 -0
- package/dist/schemas/entityPresentation.schema.d.ts.map +1 -0
- package/dist/schemas/exercise.schema.d.cts +217 -0
- package/dist/schemas/exercise.schema.d.ts +218 -0
- package/dist/schemas/exercise.schema.d.ts.map +1 -0
- package/dist/schemas/exerciseProjection.d.cts +10 -0
- package/dist/schemas/exerciseProjection.d.ts +11 -0
- package/dist/schemas/exerciseProjection.d.ts.map +1 -0
- package/dist/schemas/exerciseV2.schema.d.cts +137 -0
- package/dist/schemas/exerciseV2.schema.d.ts +138 -0
- package/dist/schemas/exerciseV2.schema.d.ts.map +1 -0
- package/dist/schemas/exerciseV3.schema.d.cts +149 -0
- package/dist/schemas/exerciseV3.schema.d.ts +150 -0
- package/dist/schemas/exerciseV3.schema.d.ts.map +1 -0
- package/dist/schemas/geometryObject.schema.d.cts +17 -0
- package/dist/schemas/geometryObject.schema.d.ts +18 -0
- package/dist/schemas/geometryObject.schema.d.ts.map +1 -0
- package/dist/schemas/geometryState.schema.d.cts +64 -0
- package/dist/schemas/geometryState.schema.d.ts +65 -0
- package/dist/schemas/geometryState.schema.d.ts.map +1 -0
- package/dist/schemas/givenElement.schema.d.cts +24 -0
- package/dist/schemas/givenElement.schema.d.ts +25 -0
- package/dist/schemas/givenElement.schema.d.ts.map +1 -0
- package/dist/schemas/goals/createGoal.schema.d.cts +12 -0
- package/dist/schemas/goals/createGoal.schema.d.ts +13 -0
- package/dist/schemas/goals/createGoal.schema.d.ts.map +1 -0
- package/dist/schemas/goals/geometryGoal.schema.d.cts +58 -0
- package/dist/schemas/goals/geometryGoal.schema.d.ts +59 -0
- package/dist/schemas/goals/geometryGoal.schema.d.ts.map +1 -0
- package/dist/schemas/goals/goalKindDeclaration.d.cts +53 -0
- package/dist/schemas/goals/goalKindDeclaration.d.ts +54 -0
- package/dist/schemas/goals/goalKindDeclaration.d.ts.map +1 -0
- package/dist/schemas/goals/midpointGoal.schema.d.cts +9 -0
- package/dist/schemas/goals/midpointGoal.schema.d.ts +10 -0
- package/dist/schemas/goals/midpointGoal.schema.d.ts.map +1 -0
- package/dist/schemas/goals/parallelGoal.schema.d.cts +15 -0
- package/dist/schemas/goals/parallelGoal.schema.d.ts +16 -0
- package/dist/schemas/goals/parallelGoal.schema.d.ts.map +1 -0
- package/dist/schemas/goals/perpendicularGoal.schema.d.cts +15 -0
- package/dist/schemas/goals/perpendicularGoal.schema.d.ts +16 -0
- package/dist/schemas/goals/perpendicularGoal.schema.d.ts.map +1 -0
- package/dist/schemas/goals/segmentLengthGoal.schema.d.cts +9 -0
- package/dist/schemas/goals/segmentLengthGoal.schema.d.ts +10 -0
- package/dist/schemas/goals/segmentLengthGoal.schema.d.ts.map +1 -0
- package/dist/schemas/goals/throughPointsGoal.schema.d.cts +13 -0
- package/dist/schemas/goals/throughPointsGoal.schema.d.ts +14 -0
- package/dist/schemas/goals/throughPointsGoal.schema.d.ts.map +1 -0
- package/dist/schemas/identifier.schema.d.cts +2 -0
- package/dist/schemas/identifier.schema.d.ts +3 -0
- package/dist/schemas/identifier.schema.d.ts.map +1 -0
- package/dist/schemas/line.schema.d.cts +7 -0
- package/dist/schemas/line.schema.d.ts +8 -0
- package/dist/schemas/line.schema.d.ts.map +1 -0
- package/dist/schemas/migrate.d.cts +21 -0
- package/dist/schemas/migrate.d.ts +22 -0
- package/dist/schemas/migrate.d.ts.map +1 -0
- package/dist/schemas/objectTypeName.schema.d.cts +8 -0
- package/dist/schemas/objectTypeName.schema.d.ts +9 -0
- package/dist/schemas/objectTypeName.schema.d.ts.map +1 -0
- package/dist/schemas/point.schema.d.cts +15 -0
- package/dist/schemas/point.schema.d.ts +16 -0
- package/dist/schemas/point.schema.d.ts.map +1 -0
- package/dist/schemas/pointOrigin.schema.d.cts +25 -0
- package/dist/schemas/pointOrigin.schema.d.ts +26 -0
- package/dist/schemas/pointOrigin.schema.d.ts.map +1 -0
- package/dist/schemas/segment.schema.d.cts +8 -0
- package/dist/schemas/segment.schema.d.ts +9 -0
- package/dist/schemas/segment.schema.d.ts.map +1 -0
- package/dist/schemas/v1ToV2.d.cts +37 -0
- package/dist/schemas/v1ToV2.d.ts +38 -0
- package/dist/schemas/v1ToV2.d.ts.map +1 -0
- package/dist/schemas/v2ToV3.d.cts +37 -0
- package/dist/schemas/v2ToV3.d.ts +38 -0
- package/dist/schemas/v2ToV3.d.ts.map +1 -0
- package/dist/schemas/v3ToV4.d.cts +3 -0
- package/dist/schemas/v3ToV4.d.ts +4 -0
- package/dist/schemas/v3ToV4.d.ts.map +1 -0
- package/dist/schemas/viewport.schema.d.cts +3 -0
- package/dist/schemas/viewport.schema.d.ts +4 -0
- package/dist/schemas/viewport.schema.d.ts.map +1 -0
- package/dist/schemas.cjs +1 -0
- package/dist/schemas.mjs +3 -0
- package/dist/snapping/computeSnap.d.cts +30 -0
- package/dist/snapping/computeSnap.d.ts +31 -0
- package/dist/snapping/computeSnap.d.ts.map +1 -0
- package/dist/state/geometryContext.d.cts +40 -0
- package/dist/state/geometryContext.d.ts +41 -0
- package/dist/state/geometryContext.d.ts.map +1 -0
- package/dist/state/resolvePoints.d.cts +51 -0
- package/dist/state/resolvePoints.d.ts +52 -0
- package/dist/state/resolvePoints.d.ts.map +1 -0
- package/dist/store/attemptChange.d.cts +30 -0
- package/dist/store/attemptChange.d.ts +31 -0
- package/dist/store/attemptChange.d.ts.map +1 -0
- package/dist/store/constructionCommand.d.cts +35 -0
- package/dist/store/constructionCommand.d.ts +36 -0
- package/dist/store/constructionCommand.d.ts.map +1 -0
- package/dist/store/constructionIdentity.d.cts +21 -0
- package/dist/store/constructionIdentity.d.ts +22 -0
- package/dist/store/constructionIdentity.d.ts.map +1 -0
- package/dist/store/createStore.d.cts +6 -0
- package/dist/store/createStore.d.ts +7 -0
- package/dist/store/createStore.d.ts.map +1 -0
- package/dist/store/history.d.cts +2 -0
- package/dist/store/history.d.ts +3 -0
- package/dist/store/history.d.ts.map +1 -0
- package/dist/store/studentStore.d.cts +151 -0
- package/dist/store/studentStore.d.ts +152 -0
- package/dist/store/studentStore.d.ts.map +1 -0
- package/dist/store/teacherStore.d.cts +111 -0
- package/dist/store/teacherStore.d.ts +112 -0
- package/dist/store/teacherStore.d.ts.map +1 -0
- package/dist/store/useStoreState.d.cts +2 -0
- package/dist/store/useStoreState.d.ts +3 -0
- package/dist/store/useStoreState.d.ts.map +1 -0
- package/dist/studentStore-CEKTtMCh.js +1162 -0
- package/dist/studentStore-CEKTtMCh.js.map +1 -0
- package/dist/studentStore-DLftfFAl.cjs +2 -0
- package/dist/studentStore-DLftfFAl.cjs.map +1 -0
- package/dist/validation/checkGeometryAnswer.d.cts +63 -0
- package/dist/validation/checkGeometryAnswer.d.ts +64 -0
- package/dist/validation/checkGeometryAnswer.d.ts.map +1 -0
- package/dist/validation/deriveGeometryCanvasFeedback.d.cts +7 -0
- package/dist/validation/deriveGeometryCanvasFeedback.d.ts +8 -0
- package/dist/validation/deriveGeometryCanvasFeedback.d.ts.map +1 -0
- package/dist/validation/exerciseLoad.d.cts +29 -0
- package/dist/validation/exerciseLoad.d.ts +30 -0
- package/dist/validation/exerciseLoad.d.ts.map +1 -0
- package/dist/validation/geometryAttemptStatus.d.cts +23 -0
- package/dist/validation/geometryAttemptStatus.d.ts +24 -0
- package/dist/validation/geometryAttemptStatus.d.ts.map +1 -0
- package/dist/validation/getGeometryExerciseWarnings.d.cts +16 -0
- package/dist/validation/getGeometryExerciseWarnings.d.ts +17 -0
- package/dist/validation/getGeometryExerciseWarnings.d.ts.map +1 -0
- package/dist/validation/goals/create.d.cts +3 -0
- package/dist/validation/goals/create.d.ts +4 -0
- package/dist/validation/goals/create.d.ts.map +1 -0
- package/dist/validation/goals/goalContext.d.cts +44 -0
- package/dist/validation/goals/goalContext.d.ts +45 -0
- package/dist/validation/goals/goalContext.d.ts.map +1 -0
- package/dist/validation/goals/goalKindHandler.d.cts +26 -0
- package/dist/validation/goals/goalKindHandler.d.ts +27 -0
- package/dist/validation/goals/goalKindHandler.d.ts.map +1 -0
- package/dist/validation/goals/index.d.cts +14 -0
- package/dist/validation/goals/index.d.ts +15 -0
- package/dist/validation/goals/index.d.ts.map +1 -0
- package/dist/validation/goals/midpoint.d.cts +4 -0
- package/dist/validation/goals/midpoint.d.ts +5 -0
- package/dist/validation/goals/midpoint.d.ts.map +1 -0
- package/dist/validation/goals/parallel.d.cts +3 -0
- package/dist/validation/goals/parallel.d.ts +4 -0
- package/dist/validation/goals/parallel.d.ts.map +1 -0
- package/dist/validation/goals/perpendicular.d.cts +3 -0
- package/dist/validation/goals/perpendicular.d.ts +4 -0
- package/dist/validation/goals/perpendicular.d.ts.map +1 -0
- package/dist/validation/goals/previewGeometry.d.cts +9 -0
- package/dist/validation/goals/previewGeometry.d.ts +10 -0
- package/dist/validation/goals/previewGeometry.d.ts.map +1 -0
- package/dist/validation/goals/segmentLength.d.cts +3 -0
- package/dist/validation/goals/segmentLength.d.ts +4 -0
- package/dist/validation/goals/segmentLength.d.ts.map +1 -0
- package/dist/validation/goals/throughPoints.d.cts +5 -0
- package/dist/validation/goals/throughPoints.d.ts +6 -0
- package/dist/validation/goals/throughPoints.d.ts.map +1 -0
- package/dist/validation/gradingFingerprints.d.cts +4 -0
- package/dist/validation/gradingFingerprints.d.ts +5 -0
- package/dist/validation/gradingFingerprints.d.ts.map +1 -0
- package/dist/validation/revealGeometryAnswers.d.cts +3 -0
- package/dist/validation/revealGeometryAnswers.d.ts +4 -0
- package/dist/validation/revealGeometryAnswers.d.ts.map +1 -0
- package/dist/validation/revealGeometryAttempt.d.cts +36 -0
- package/dist/validation/revealGeometryAttempt.d.ts +37 -0
- package/dist/validation/revealGeometryAttempt.d.ts.map +1 -0
- package/dist/validation/types.d.cts +58 -0
- package/dist/validation/types.d.ts +59 -0
- package/dist/validation/types.d.ts.map +1 -0
- package/dist/validation/validateGeometryExercise.d.cts +30 -0
- package/dist/validation/validateGeometryExercise.d.ts +31 -0
- package/dist/validation/validateGeometryExercise.d.ts.map +1 -0
- package/dist/validation/validateGeometryState.d.cts +16 -0
- package/dist/validation/validateGeometryState.d.ts +17 -0
- package/dist/validation/validateGeometryState.d.ts.map +1 -0
- package/dist/validation/validateGeometryStateInput.d.cts +22 -0
- package/dist/validation/validateGeometryStateInput.d.ts +23 -0
- package/dist/validation/validateGeometryStateInput.d.ts.map +1 -0
- package/dist/validation.cjs +2 -0
- package/dist/validation.cjs.map +1 -0
- package/dist/validation.mjs +269 -0
- package/dist/validation.mjs.map +1 -0
- package/package.json +150 -0
package/README.md
ADDED
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
# Geometry Engine
|
|
2
|
+
|
|
3
|
+
`@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.
|
|
4
|
+
|
|
5
|
+
- **Konva / react-konva** power the visible canvas, pointer interactions, and the draggable instrument overlays (ruler, set square, protractor).
|
|
6
|
+
- **JSXGraph** is the geometry kernel behind `src/measure/`: projective intersections during answer validation and snapping. No board, no DOM.
|
|
7
|
+
- **Zod** schemas define and validate the exercise and student-state data model, including cross-reference checks (duplicate ids, missing point references, inconsistent goals).
|
|
8
|
+
- **Geometry Engine** itself owns the exercise model, student state, pedagogical rules, tolerances, stores, and visual feedback.
|
|
9
|
+
|
|
10
|
+
## Status
|
|
11
|
+
|
|
12
|
+
Version 0.1.0. Publication to the public npm registry is decided
|
|
13
|
+
([ADR 0007](./docs/adr/0007-audience-and-distribution.md)) and has not happened yet; what a release
|
|
14
|
+
promises and what has to be green before one is published are in
|
|
15
|
+
[docs/releasing.md](./docs/releasing.md), and what has changed is in [CHANGELOG.md](./CHANGELOG.md).
|
|
16
|
+
The current feature set:
|
|
17
|
+
|
|
18
|
+
| Area | Supported today |
|
|
19
|
+
| ----------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
20
|
+
| Objects | `point`, `segment`, `line` |
|
|
21
|
+
| Goals | `create`, `throughPoints`, `perpendicular`, `parallel`, `segmentLength`, `midpoint` (all tolerances configurable) |
|
|
22
|
+
| Instruments | `ruler`, `setSquare`, `protractor` — **visual aids only**: draggable, rotatable, no mathematical effect |
|
|
23
|
+
| Modes | Student solving (`StudentGeometryCanvas`) and teacher authoring (`GeometryCanvas`), both with undo/redo |
|
|
24
|
+
| Validation | Structural (Zod) and geometric (JSXGraph kernel) — both run in bare Node, no DOM |
|
|
25
|
+
| Feedback | Per-goal correct/incorrect status, matched/attempted construction highlighting, answer reveal |
|
|
26
|
+
|
|
27
|
+
Where this is going: [FUTURE.md](./FUTURE.md) is the long-range feature map, [docs/implementation/ROADMAP.md](./docs/implementation/ROADMAP.md) is the executable order, and [docs/adr/](./docs/adr/) records the decisions already made.
|
|
28
|
+
|
|
29
|
+
### Support matrix
|
|
30
|
+
|
|
31
|
+
"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.
|
|
32
|
+
|
|
33
|
+
| Area | Support | Status |
|
|
34
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
35
|
+
| Node | `^20.19.0 \|\| >=22.12.0` (`engines`) | Verified in CI on 20.19.0, 22.12.0, and current LTS |
|
|
36
|
+
| 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](./docs/adr/0007-audience-and-distribution.md)) |
|
|
37
|
+
| Vitest + jsdom | `StudentGeometryExercise` mounts and takes Pointer Events with two stubs — [Test a host with Vitest and jsdom](#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 |
|
|
38
|
+
| Konva | `konva` `^10.3.0` and `react-konva` `^19.2.5`, both peers | Unverified against other majors |
|
|
39
|
+
| 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 |
|
|
40
|
+
| 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 |
|
|
41
|
+
| Public API shape | Signatures and types snapshotted in [etc/api-report.md](./etc/api-report.md) | Verified by `pnpm run verify:api` on every run |
|
|
42
|
+
| Bundle size | Per-entry-point budgets in [etc/bundle-size.json](./etc/bundle-size.json) | Verified by `pnpm run verify:size`; measurements in [docs/releasing.md](./docs/releasing.md) |
|
|
43
|
+
| 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 |
|
|
44
|
+
| Mouse | Full: place, drag, connect, select | Verified in Chromium and WebKit by `tests/e2e/` — place, snap, drag, connect, select, delete |
|
|
45
|
+
| 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** |
|
|
46
|
+
| 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 |
|
|
47
|
+
| 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 |
|
|
48
|
+
| Structural validation | Bare Node, no DOM | Verified by the unit suite |
|
|
49
|
+
| Geometric validation | Bare Node, no DOM, no global state | Verified by `tests/measure/serverGrading.test.ts`, which runs with `document` undefined throughout |
|
|
50
|
+
|
|
51
|
+
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](./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.
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
> **Publication status:** `@mathscan/geometry-engine` is a public npm package by decision
|
|
56
|
+
> ([ADR 0007](./docs/adr/0007-audience-and-distribution.md)), and the first publication has not
|
|
57
|
+
> been made yet. Until it is, install the packed archive; the release checklist is in
|
|
58
|
+
> [docs/releasing.md](./docs/releasing.md).
|
|
59
|
+
|
|
60
|
+
Once published:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
pnpm add @mathscan/geometry-engine react react-dom konva react-konva
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Until then, build and pack the library:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
pnpm install
|
|
70
|
+
pnpm build
|
|
71
|
+
pnpm pack
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
and install the generated `.tgz` archive in the consuming application:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
pnpm add /path/to/mathscan-geometry-engine-0.1.0.tgz
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Either way the consuming application gets the same archive contents — `dist`, `package.json` and
|
|
81
|
+
`README.md`, nothing else — which `pnpm run verify:package` checks on every run.
|
|
82
|
+
|
|
83
|
+
`react`, `react-dom`, `konva`, and `react-konva` are peer dependencies. `zod` and `jsxgraph` are installed as runtime dependencies.
|
|
84
|
+
|
|
85
|
+
### Entry points
|
|
86
|
+
|
|
87
|
+
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:
|
|
88
|
+
|
|
89
|
+
| Import | Contains | Server-safe |
|
|
90
|
+
| -------------------------------------- | --------------------------------------------------------- | ----------- |
|
|
91
|
+
| `@mathscan/geometry-engine` | the union of the four below | no |
|
|
92
|
+
| `@mathscan/geometry-engine/core` | entity model, geometry, coordinates, snapping, the stores | yes |
|
|
93
|
+
| `@mathscan/geometry-engine/schemas` | the stored documents, their goals, and their migrations | yes |
|
|
94
|
+
| `@mathscan/geometry-engine/validation` | grading, feedback, answer reveal, the measurement seam | yes |
|
|
95
|
+
| `@mathscan/geometry-engine/react` | canvases, shapes, the toolbar, and the store hook | no |
|
|
96
|
+
|
|
97
|
+
Each resolves in both of Node's module systems: `import` gets ESM with `.d.ts` declarations, `require` gets CommonJS with `.d.cts` declarations.
|
|
98
|
+
|
|
99
|
+
[docs/api-surface.md](./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.
|
|
100
|
+
|
|
101
|
+
## Architecture
|
|
102
|
+
|
|
103
|
+
For a plain-language overview, see [How the Geometry Engine Works](./docs/PROJECT_FLOW.md).
|
|
104
|
+
|
|
105
|
+
The source is organized into small, dependency-directed modules:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
src/
|
|
109
|
+
├── index.ts Public API surface (everything is exported from here)
|
|
110
|
+
├── geometry/ Plain geometric types and math (Point, GeometryObject, distances, hit testing)
|
|
111
|
+
├── coordinates/ Screen <-> math coordinate transforms, grid steps, viewport line clipping
|
|
112
|
+
├── schemas/ Zod schemas: exercise, geometry state, objects, goals, viewport
|
|
113
|
+
│ └── goals/ One schema per goal kind (create, throughPoints, perpendicular, parallel, segmentLength, midpoint), plus each kind's authoring declaration
|
|
114
|
+
├── snapping/ Where a placed point lands (points, intersections, grid, objects)
|
|
115
|
+
├── state/ Resolves stored point origins into coordinates
|
|
116
|
+
├── store/ Framework-agnostic store primitive + teacher and student stores
|
|
117
|
+
├── validation/ Answer checking, exercise validation, feedback derivation
|
|
118
|
+
│ └── goals/ One handler per goal kind (accepts / satisfies / isRelevantAttempt / reveal)
|
|
119
|
+
├── measure/ The one JSXGraph boundary: kernel.ts (JXG) + geometryAdapter.ts
|
|
120
|
+
└── renderer/ React/Konva components: canvases, toolbar, grid, instruments
|
|
121
|
+
└── shapes/ Point, segment, and line Konva shapes
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Key design decisions, recorded in full under [docs/adr/](./docs/adr/):
|
|
125
|
+
|
|
126
|
+
- **Exercise vs. state separation.** `GeometryExercise` (the authored definition) and `GeometryState` (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](./docs/adr/0001-exercise-attempt-separation.md)).
|
|
127
|
+
- **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.
|
|
128
|
+
- **Pluggable goal kinds.** Each goal type is a `GoalKindHandler` with 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), and `buildRevealedAnswer()` (synthesize a canonical answer shape). Adding a goal kind means adding one schema and one handler.
|
|
129
|
+
- **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](./docs/adr/0004-measurement-seam.md), [ADR 0005](./docs/adr/0005-jsxgraph-as-geometry-kernel.md)). Inside the seam the split is again one file deep: `kernel.ts` is the only file that imports JXG, and `geometryAdapter.ts` exposes named measurements built on it.
|
|
130
|
+
- **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](./docs/adr/0006-no-jsxgraph-board.md)).
|
|
131
|
+
- **Renderer-independent transforms.** `coordinates/transform.ts` maps the math viewport (y-up) onto canvas pixels (y-down) with `contain` (angle-preserving, default) or `stretch` projections; both rendering and pointer conversion consume the same placement.
|
|
132
|
+
|
|
133
|
+
## Core model
|
|
134
|
+
|
|
135
|
+
- `GeometryExercise` describes the viewport, the authored `entities`, how they are presented, which of them a student may drag, the allowed constructions, instruments, and goals.
|
|
136
|
+
- `GeometryState` holds the student's own `entities` — one record for every kind — plus their `presentation` and `answerEntityIds`, the list of what counts as their answer.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import type { GeometryExercise, GeometryState } from '@mathscan/geometry-engine'
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Example exercise
|
|
143
|
+
|
|
144
|
+
This exercise asks the student to construct segment `AB`:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import type { GeometryExercise } from '@mathscan/geometry-engine'
|
|
148
|
+
|
|
149
|
+
const exercise: GeometryExercise = {
|
|
150
|
+
schemaVersion: 4,
|
|
151
|
+
id: 'segment-ab',
|
|
152
|
+
viewport: [0, 0, 10, 10],
|
|
153
|
+
showGrid: true,
|
|
154
|
+
// One record for every kind. v1's `givens[]` held four shapes with four
|
|
155
|
+
// vocabularies; a v2 entity is `{ kind, ... }` and a point's coordinates are
|
|
156
|
+
// its origin, not two loose fields.
|
|
157
|
+
entities: {
|
|
158
|
+
A: { kind: 'point', at: { x: 2, y: 3 } },
|
|
159
|
+
B: { kind: 'point', at: { x: 8, y: 3 } },
|
|
160
|
+
},
|
|
161
|
+
// How an entity is shown, kept out of what it *is*. A label and a role live
|
|
162
|
+
// here so that making a point visible never touches its definition.
|
|
163
|
+
presentation: {
|
|
164
|
+
A: { role: 'explicit', label: 'A' },
|
|
165
|
+
B: { role: 'explicit', label: 'B' },
|
|
166
|
+
},
|
|
167
|
+
// v1's per-given `draggable` flag. It is a list on the exercise and
|
|
168
|
+
// unrepresentable in an attempt, so a tampered attempt cannot declare a
|
|
169
|
+
// pinned given draggable and move what its own goals are measured against.
|
|
170
|
+
draggableEntityIds: [],
|
|
171
|
+
allowedObjects: ['segment'],
|
|
172
|
+
tools: ['ruler'],
|
|
173
|
+
goals: [
|
|
174
|
+
{
|
|
175
|
+
id: 'goal-ab',
|
|
176
|
+
type: 'throughPoints',
|
|
177
|
+
objectType: 'segment',
|
|
178
|
+
pointIds: ['A', 'B'],
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Supported objects are `point`, `segment`, `line`, and `circle`. A circle is stored as
|
|
185
|
+
`{ id, type: 'circle', center, through }` — the id of its centre point and the id of a point it
|
|
186
|
+
passes through. Its radius is measured from those two on every evaluation and never stored, so
|
|
187
|
+
dragging either one changes the circle and everything built on it. There is no compass tool and no
|
|
188
|
+
circle goal kind yet: a circle can be authored as a given, persisted in an attempt, resolved,
|
|
189
|
+
intersected, snapped to and drawn, which is what M2.8 landed. Supported goal kinds are currently `create`, `throughPoints`, `perpendicular`, `parallel`, `segmentLength`, and `midpoint`.
|
|
190
|
+
|
|
191
|
+
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.
|
|
192
|
+
|
|
193
|
+
## Hit testing
|
|
194
|
+
|
|
195
|
+
`hitTest()` answers "what is under the pointer" in math space, with no renderer involved:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import { deriveStudentRenderableGeometry, hitTest } from '@mathscan/geometry-engine'
|
|
199
|
+
|
|
200
|
+
// Coordinates are derived, never stored, so the scene comes from resolving the
|
|
201
|
+
// exercise and the attempt together. `RenderableGeometry` is already
|
|
202
|
+
// `{ points, objects }`, which is exactly what `hitTest()` takes.
|
|
203
|
+
const scene = deriveStudentRenderableGeometry(exercise, geometry)
|
|
204
|
+
|
|
205
|
+
const hits = hitTest({ x: 2, y: 1.05 }, scene, 0.3)
|
|
206
|
+
// [{ id: 'P1', type: 'point', distance: 0.05 }, { id: 'ab', type: 'segment', distance: 0.28 }]
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
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).
|
|
210
|
+
|
|
211
|
+
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:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
import {
|
|
215
|
+
CONNECTION_SNAP_RADIUS_PX,
|
|
216
|
+
computePlacement,
|
|
217
|
+
screenLengthToMath,
|
|
218
|
+
} from '@mathscan/geometry-engine'
|
|
219
|
+
|
|
220
|
+
const placement = computePlacement(exercise.viewport, canvas)
|
|
221
|
+
const tolerance = screenLengthToMath(CONNECTION_SNAP_RADIUS_PX, placement)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`distancePointToSegment()` and `distancePointToLine()` are exported for direct use; both are pure math-space functions.
|
|
225
|
+
|
|
226
|
+
## Numerical policy
|
|
227
|
+
|
|
228
|
+
Every epsilon that governs a geometric decision is named once, in `src/numerics/policy.ts`, and
|
|
229
|
+
exported. Nothing else in the engine writes a numeric literal as a tolerance. The module separates
|
|
230
|
+
three things that are easy to confuse:
|
|
231
|
+
|
|
232
|
+
| Kind | Examples | Who sets it |
|
|
233
|
+
| ------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------- |
|
|
234
|
+
| **Calculation epsilon** | `CALCULATION_EPSILON`, `COORDINATE_EPSILON`, `PARAMETER_EPSILON`, `POINT_REUSE_EPSILON` | the engine — a property of IEEE 754 |
|
|
235
|
+
| **Grading tolerance** | `toleranceUnits`, `toleranceDegrees` on a goal | the author, in the exercise |
|
|
236
|
+
| **Interaction tolerance** | `PLACEMENT_SNAP_RADIUS_PX`, `CONNECTION_SNAP_RADIUS_PX` | the engine, in pixels, converted per canvas |
|
|
237
|
+
|
|
238
|
+
Grading never consults a global tolerance: the only constant it may add to an authored tolerance is
|
|
239
|
+
`GRADING_BOUNDARY_EPSILON`, so an answer exactly on the boundary is not rejected by floating-point
|
|
240
|
+
noise. Degeneracy is measured relative to the size of the object it is asked about
|
|
241
|
+
(`isDegenerateAtScale`) rather than against a fixed size, so the same construction behaves the same
|
|
242
|
+
way whether it is drawn in thousandths or in thousands.
|
|
243
|
+
|
|
244
|
+
The policy is exercised by property-based tests (`fast-check`, in `tests/numerics/`) covering
|
|
245
|
+
distance as a metric, endpoint-order invariance, transform round trips, translation and rotation
|
|
246
|
+
invariance, on-object snaps lying on their target, intersections lying on both parents, and segment
|
|
247
|
+
parameters staying in `[0, 1]` — at three coordinate scales. Runs are seeded, so a failure prints a
|
|
248
|
+
replay seed; set `FC_SEED` to reproduce one and `FC_RUNS` to soak locally. Counterexamples the
|
|
249
|
+
properties have already found are pinned as ordinary tests in `tests/numerics/regressions.test.ts`.
|
|
250
|
+
|
|
251
|
+
## Point origins and the dependency graph
|
|
252
|
+
|
|
253
|
+
A point in `GeometryState` stores _why_ it is where it is, not a frozen pair of coordinates:
|
|
254
|
+
|
|
255
|
+
| Origin | Meaning |
|
|
256
|
+
| ---------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
257
|
+
| `{ x, y }` | free — the point is simply there |
|
|
258
|
+
| `{ onObject: 'ab', t }` | on an object, at parameter `t` along its two defining points (`0` is the first, `1` the second) |
|
|
259
|
+
| `{ intersectionOf: ['ab', 'cd'] }` | where two objects cross |
|
|
260
|
+
|
|
261
|
+
On a circle, `t` is a **turn** rather than a run between two points: `0` is the point at angle zero
|
|
262
|
+
from the centre, `0.25` a quarter turn counterclockwise, and `1` a full turn, so every value is on
|
|
263
|
+
the circle and `t` and `t + 1` name the same place. It is measured from the positive x-axis and not
|
|
264
|
+
from the circle's own through-point, so that two definitions of the same circle put a point at `t`
|
|
265
|
+
in the same place.
|
|
266
|
+
|
|
267
|
+
Two objects can cross twice — a line and a circle, or two circles — so
|
|
268
|
+
`{ intersectionOf: ['c1', 'c2'], which: 1 }` selects the second crossing. Crossings are ordered by
|
|
269
|
+
coordinate rather than by whatever order the operands were given in, so a stored `which` still names
|
|
270
|
+
the same point after an author reverses a line's two points.
|
|
271
|
+
|
|
272
|
+
Coordinates are always derived, never stored alongside, so no cached copy can go stale:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
import { resolvePoints } from '@mathscan/geometry-engine'
|
|
276
|
+
|
|
277
|
+
const points = resolvePoints(exercise, geometry) // Map<PointId, Point>
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
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.
|
|
281
|
+
|
|
282
|
+
An unresolved point is **not** discarded. Its origin stays in the document, so it comes back the
|
|
283
|
+
moment its parents make sense again: drag two parallel objects back across each other and the
|
|
284
|
+
intersection point returns, keeping its id and everything built on it. Only an explicit deletion
|
|
285
|
+
removes anything, and that takes the dependent closure with it.
|
|
286
|
+
|
|
287
|
+
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](./docs/adr/0002-one-way-dependency-graph.md), [ADR 0003](./docs/adr/0003-derived-point-origins.md)).
|
|
288
|
+
|
|
289
|
+
Consequences elsewhere in the API:
|
|
290
|
+
|
|
291
|
+
- `addStudentPoint(store, origin)` takes an origin. `{ x, y }` is still valid — that is a free point — while snapped placement passes `{ onObject, t }` or `{ intersectionOf }`.
|
|
292
|
+
- `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.
|
|
293
|
+
- `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.
|
|
294
|
+
- Validation resolves before checking, so a goal is judged on where the figure actually is.
|
|
295
|
+
|
|
296
|
+
## Save and restore an attempt
|
|
297
|
+
|
|
298
|
+
A `GeometryState` carries its own `schemaVersion`, so a stored attempt can be restored later — resume after a refresh, teacher review, analytics:
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
import {
|
|
302
|
+
createStudentStore,
|
|
303
|
+
serializeGeometryState,
|
|
304
|
+
validateGeometryStateJson,
|
|
305
|
+
} from '@mathscan/geometry-engine'
|
|
306
|
+
|
|
307
|
+
// Save
|
|
308
|
+
const saved = serializeGeometryState(store.getState().geometry)
|
|
309
|
+
|
|
310
|
+
// Restore, checked against the exercise it belongs to
|
|
311
|
+
const restored = validateGeometryStateJson(saved, exercise)
|
|
312
|
+
if (restored.success) {
|
|
313
|
+
const store = createStudentStore(exercise, restored.data)
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`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.
|
|
318
|
+
|
|
319
|
+
### Versions and migration
|
|
320
|
+
|
|
321
|
+
**The two documents version independently.** The exercise is at version **4** and the attempt is at version **2**. A v2 attempt is loaded and graded against a v4 exercise, and neither version implies the other. Version 3 exists for one optional field, `grading` (see [Strict grading](#strict-grading) below). Version 4 exists for one optional field, `solution`, the authored canonical attempt (see [Reveal answers](#reveal-answers)). 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.
|
|
322
|
+
|
|
323
|
+
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.
|
|
324
|
+
|
|
325
|
+
**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.
|
|
326
|
+
|
|
327
|
+
**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.
|
|
328
|
+
|
|
329
|
+
**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.
|
|
330
|
+
|
|
331
|
+
**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.
|
|
332
|
+
|
|
333
|
+
**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:
|
|
334
|
+
|
|
335
|
+
- **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.
|
|
336
|
+
- **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.
|
|
337
|
+
- **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.
|
|
338
|
+
- **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.
|
|
339
|
+
|
|
340
|
+
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.
|
|
341
|
+
|
|
342
|
+
**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.
|
|
343
|
+
|
|
344
|
+
## Snapping
|
|
345
|
+
|
|
346
|
+
Both canvases snap a placed point instead of taking the raw pixel, so clicking near a landmark lands on exact coordinates. `computeSnap()` decides where:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
import { computeSnap } from '@mathscan/geometry-engine'
|
|
350
|
+
|
|
351
|
+
const snap = computeSnap(
|
|
352
|
+
pointerInMathSpace,
|
|
353
|
+
{ points, objects, viewport: exercise.viewport, showGrid: exercise.showGrid },
|
|
354
|
+
{ tolerance },
|
|
355
|
+
)
|
|
356
|
+
// { position: { x: 2, y: 2 }, kind: 'intersection', targetIds: ['ab', 'cd'], distance: 0.14 }
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
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:
|
|
360
|
+
|
|
361
|
+
| Kind | Snaps to |
|
|
362
|
+
| -------------- | ----------------------------------------------------- |
|
|
363
|
+
| `point` | an existing point, given or constructed |
|
|
364
|
+
| `intersection` | where two objects cross, even with no point there yet |
|
|
365
|
+
| `grid` | a grid crossing, only when the grid is shown |
|
|
366
|
+
| `onObject` | the nearest place on a segment or line |
|
|
367
|
+
|
|
368
|
+
`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.
|
|
369
|
+
|
|
370
|
+
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.
|
|
371
|
+
|
|
372
|
+
`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.
|
|
373
|
+
|
|
374
|
+
## Validate exercise data
|
|
375
|
+
|
|
376
|
+
Use the safe validation facade for data loaded from JSON, an API, or another untrusted source:
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
import { validateGeometryExerciseJson } from '@mathscan/geometry-engine'
|
|
380
|
+
|
|
381
|
+
const validation = validateGeometryExerciseJson(json)
|
|
382
|
+
|
|
383
|
+
if (!validation.success) {
|
|
384
|
+
console.error(validation.errors)
|
|
385
|
+
} else {
|
|
386
|
+
console.log(validation.data)
|
|
387
|
+
console.log(validation.warnings)
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
- `validateGeometryExercise(input)` validates an unknown value without throwing.
|
|
392
|
+
- `validateGeometryExerciseJson(json)` parses and validates a JSON string.
|
|
393
|
+
- `parseGeometryExercise(input)` returns a typed exercise or throws a Zod error.
|
|
394
|
+
- `getGeometryExerciseWarnings(exercise)` reports pedagogical issues that are legal but suspicious.
|
|
395
|
+
- Unknown properties, duplicate identifiers, duplicate geometry, missing references, and inconsistent goals are rejected by the schema's cross-reference checks.
|
|
396
|
+
- **Entity ids: `__proto__` is the only name refused.** Every other id is legal, `constructor`, `toString`, `valueOf` and `hasOwnProperty` included — 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 an `entities` or `presentation` record yourself, use the exported `entryOf(record, id)` rather than `record[id]` — a bare index answers for `constructor` with a function off the prototype chain.
|
|
397
|
+
|
|
398
|
+
Structural validation works without a canvas and can run in Node.js.
|
|
399
|
+
|
|
400
|
+
## Embed a student exercise
|
|
401
|
+
|
|
402
|
+
`StudentGeometryExercise` is the whole student side in one component: tools, Undo, Redo, Delete,
|
|
403
|
+
Reset, the construction notice, a canvas that fills its container's width and follows it as it
|
|
404
|
+
resizes, and the semantic companion. Your application keeps Check, Reveal, saving and the wording of
|
|
405
|
+
results.
|
|
406
|
+
|
|
407
|
+
```tsx
|
|
408
|
+
import { StudentGeometryExercise } from '@mathscan/geometry-engine/react'
|
|
409
|
+
import type { GeometryState } from '@mathscan/geometry-engine/schemas'
|
|
410
|
+
import { checkGeometryAnswer } from '@mathscan/geometry-engine/validation'
|
|
411
|
+
import { useState } from 'react'
|
|
412
|
+
|
|
413
|
+
function Exercise({ exercise, saved }: { exercise: unknown; saved?: GeometryState }) {
|
|
414
|
+
const [attempt, setAttempt] = useState(saved)
|
|
415
|
+
const [checked, setChecked] = useState<ReturnType<typeof checkGeometryAnswer> | null>(null)
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<>
|
|
419
|
+
<StudentGeometryExercise
|
|
420
|
+
exercise={exercise}
|
|
421
|
+
value={attempt}
|
|
422
|
+
// Hand the attempt straight back: an echo keeps Undo. `meta.action` says
|
|
423
|
+
// what the student did, for a sound or an effect.
|
|
424
|
+
onChange={(next) => setAttempt(next)}
|
|
425
|
+
validationFeedback={checked}
|
|
426
|
+
/>
|
|
427
|
+
<button onClick={() => setChecked(checkGeometryAnswer(exercise, attempt))}>Check</button>
|
|
428
|
+
</>
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
- **Controlled.** `exercise` and `value` are untrusted and parsed on every render. Both are compared
|
|
434
|
+
by content, so a copy or a JSON round trip of the attempt you were given is recognized as the same
|
|
435
|
+
value, and an object you edit in place is seen. A different value replaces the attempt, and a
|
|
436
|
+
different exercise starts a new session. Neither calls `onChange`, and both clear Undo and cancel a
|
|
437
|
+
gesture in progress. A change to the exercise's `solution` alone is not a new exercise. An absent
|
|
438
|
+
`value` (`undefined` or `null`) is a fresh attempt.
|
|
439
|
+
- **A new value is one that differs from the last one you passed.** Passing back the attempt
|
|
440
|
+
`onChange` gave you is how the component stays in step. A host that never does cannot reset by
|
|
441
|
+
passing the same empty attempt again: use Reset, or pass a changed value.
|
|
442
|
+
- **`onChange`** fires once per completed student action, with `{ action, entityIds }`, and never for
|
|
443
|
+
drag frames. It receives a copy, so editing it does not touch the component. A refused duplicate is
|
|
444
|
+
not a change: it is shown, and reported to `onNotice`.
|
|
445
|
+
- **`validationFeedback`** takes the whole `checkGeometryAnswer()` result. It is shown only while the
|
|
446
|
+
exercise and attempt still match what was checked, so an edit hides it immediately.
|
|
447
|
+
- **`readOnly`** locks the attempt in the store, not only in the controls; replacing `value` still
|
|
448
|
+
works. `controls={{ toolbar, history, delete, reset }}` hides built-in controls you replace with
|
|
449
|
+
your own.
|
|
450
|
+
- **Invalid input** shows an alert, calls `onError` once per distinct list of issues, and leaves the
|
|
451
|
+
current session untouched. The built-in wording is English.
|
|
452
|
+
- Every mounted component has its own store. Size it with CSS on `className`; the canvas takes the
|
|
453
|
+
full width at the exercise viewport's aspect ratio.
|
|
454
|
+
|
|
455
|
+
### Test a host with Vitest and jsdom
|
|
456
|
+
|
|
457
|
+
jsdom has no canvas backend and no layout, and the component needs both: Konva draws through
|
|
458
|
+
`getContext('2d')`, which jsdom answers with `null`, and the canvas is sized by a `ResizeObserver`,
|
|
459
|
+
which jsdom does not implement. Add a setup file with two stubs:
|
|
460
|
+
|
|
461
|
+
```js
|
|
462
|
+
// vitest.config.js: test: { environment: 'jsdom', setupFiles: ['./vitest.setup.js'] }
|
|
463
|
+
|
|
464
|
+
HTMLCanvasElement.prototype.getContext = function getContext() {
|
|
465
|
+
const context = {
|
|
466
|
+
canvas: this,
|
|
467
|
+
measureText: () => ({ width: 0 }),
|
|
468
|
+
getImageData: () => ({ data: [] }),
|
|
469
|
+
createLinearGradient: () => ({ addColorStop() {} }),
|
|
470
|
+
createRadialGradient: () => ({ addColorStop() {} }),
|
|
471
|
+
createPattern: () => null,
|
|
472
|
+
}
|
|
473
|
+
// Anything else Konva calls becomes a no-op the first time it is read.
|
|
474
|
+
return new Proxy(context, {
|
|
475
|
+
get: (target, property) =>
|
|
476
|
+
property in target || typeof property !== 'string'
|
|
477
|
+
? target[property]
|
|
478
|
+
: (target[property] = () => {}),
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
globalThis.ResizeObserver = class ResizeObserver {
|
|
483
|
+
constructor(callback) {
|
|
484
|
+
this.callback = callback
|
|
485
|
+
}
|
|
486
|
+
observe(target) {
|
|
487
|
+
this.callback([{ target, contentRect: { width: 400, height: 300 } }], this)
|
|
488
|
+
}
|
|
489
|
+
unobserve() {}
|
|
490
|
+
disconnect() {}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
With it, the component mounts, its buttons work, and Pointer Events dispatched on its
|
|
497
|
+
`.konvajs-content` element reach the gesture lifecycle; jsdom lays the stage out at the origin, so
|
|
498
|
+
`clientX` and `clientY` are stage pixels. Two limits: nothing is drawn, and Konva's hit canvas finds
|
|
499
|
+
nothing, so **every press lands on bare canvas** — a press cannot select or drag a drawn point. Test
|
|
500
|
+
those in a real browser. `tests/consumer/` runs exactly this setup against the packed package on
|
|
501
|
+
every `pnpm run check`.
|
|
502
|
+
|
|
503
|
+
Run these tests with `NODE_ENV` unset or `test`, never `production`. Under `production`, Vite
|
|
504
|
+
resolves the jsdom environment like a browser build, so `node:` imports fail to load, and React's
|
|
505
|
+
production build has no `act()`.
|
|
506
|
+
|
|
507
|
+
## Render a student exercise
|
|
508
|
+
|
|
509
|
+
For a composition of your own, create one student store per attempt and pass it to the student
|
|
510
|
+
canvas:
|
|
511
|
+
|
|
512
|
+
```tsx
|
|
513
|
+
import {
|
|
514
|
+
createStudentStore,
|
|
515
|
+
selectStudentObjectType,
|
|
516
|
+
StudentGeometryCanvas,
|
|
517
|
+
Toolbar,
|
|
518
|
+
useStoreState,
|
|
519
|
+
type GeometryExercise,
|
|
520
|
+
} from '@mathscan/geometry-engine'
|
|
521
|
+
import { useState } from 'react'
|
|
522
|
+
|
|
523
|
+
function StudentExercise({ exercise }: { exercise: GeometryExercise }) {
|
|
524
|
+
const [store] = useState(() => createStudentStore(exercise))
|
|
525
|
+
const state = useStoreState(store)
|
|
526
|
+
|
|
527
|
+
return (
|
|
528
|
+
<>
|
|
529
|
+
<Toolbar
|
|
530
|
+
selected={state.pendingObjectType}
|
|
531
|
+
available={exercise.allowedObjects}
|
|
532
|
+
onSelect={(type) => selectStudentObjectType(store, type)}
|
|
533
|
+
/>
|
|
534
|
+
<StudentGeometryCanvas store={store} size={{ width: 720, height: 540 }} />
|
|
535
|
+
</>
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
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`.
|
|
541
|
+
|
|
542
|
+
### One gesture, one command
|
|
543
|
+
|
|
544
|
+
`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:
|
|
545
|
+
|
|
546
|
+
```ts
|
|
547
|
+
executeConstruction(store, {
|
|
548
|
+
tool: 'segment',
|
|
549
|
+
from: { kind: 'existing', id: 'A' },
|
|
550
|
+
to: { kind: 'create', origin: { kind: 'point', intersectionOf: ['ab', 'cd'] } },
|
|
551
|
+
})
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Three rules follow from that, and all three are visible to a host ([RFC 0001](./docs/rfc/0001-generalized-construction-architecture.md) § 11):
|
|
555
|
+
|
|
556
|
+
- **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.
|
|
557
|
+
- **An invented endpoint is not the answer.** It is written `role: 'implicit'` and left out of `answerEntityIds`, so a `create` goal 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.
|
|
558
|
+
- **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.
|
|
559
|
+
|
|
560
|
+
**A construction that is already there is not built twice** ([RFC 0004](./docs/rfc/0004-core-workflow-hardening.md) § 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.
|
|
561
|
+
|
|
562
|
+
`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.
|
|
563
|
+
|
|
564
|
+
### Undo, redo, and deletion
|
|
565
|
+
|
|
566
|
+
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).
|
|
567
|
+
|
|
568
|
+
**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](./docs/adr/0009-construction-commands-and-provenance.md) § Q3.
|
|
569
|
+
|
|
570
|
+
**`resetStudentAttempt(store)` starts the attempt over** ([RFC 0004](./docs/rfc/0004-core-workflow-hardening.md) § 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.
|
|
571
|
+
|
|
572
|
+
`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).
|
|
573
|
+
|
|
574
|
+
The exercise's `tools` render as draggable, rotatable instrument overlays (`GeometryInstruments`) on top of the canvas.
|
|
575
|
+
|
|
576
|
+
**Instruments are visual aids with no mathematical effect.** The ruler, set square, and protractor
|
|
577
|
+
are pictures the student can move and rotate. They constrain nothing, measure nothing, snap to
|
|
578
|
+
nothing, and are invisible to validation — listing a tool in `tools` decorates the canvas, it does
|
|
579
|
+
not require the tool's use. No goal can express "construct this with the compass", and no check can
|
|
580
|
+
tell whether an instrument was touched. Functional instruments, including the missing compass, are
|
|
581
|
+
deferred beyond the focused Milestone 3.
|
|
582
|
+
|
|
583
|
+
## Check a student's answer
|
|
584
|
+
|
|
585
|
+
`checkGeometryAnswer()` validates both inputs before evaluating the goals:
|
|
586
|
+
|
|
587
|
+
```ts
|
|
588
|
+
import { checkGeometryAnswer } from '@mathscan/geometry-engine'
|
|
589
|
+
|
|
590
|
+
const checked = checkGeometryAnswer(exercise, store.getState().geometry)
|
|
591
|
+
|
|
592
|
+
if (!checked.success) {
|
|
593
|
+
console.error(checked.errors)
|
|
594
|
+
} else {
|
|
595
|
+
console.log(checked.data.result.status) // "correct" or "incorrect"
|
|
596
|
+
console.log(checked.data.goals) // matches used by visual feedback
|
|
597
|
+
}
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
### Why an answer is wrong
|
|
601
|
+
|
|
602
|
+
`checkGeometryAnswerDetailed()` takes the same untrusted inputs and reports the reasoning behind the
|
|
603
|
+
verdict, as stable codes rather than prose:
|
|
604
|
+
|
|
605
|
+
```ts
|
|
606
|
+
import { checkGeometryAnswerDetailed } from '@mathscan/geometry-engine/validation'
|
|
607
|
+
|
|
608
|
+
const checked = checkGeometryAnswerDetailed(exerciseJson, attemptJson)
|
|
609
|
+
|
|
610
|
+
if (!checked.success) {
|
|
611
|
+
// 'invalid-exercise' or 'invalid-attempt' — a malformed document, not a wrong answer.
|
|
612
|
+
console.error(checked.errors.map((issue) => issue.code))
|
|
613
|
+
} else {
|
|
614
|
+
for (const goal of checked.data.goals) {
|
|
615
|
+
// 'goal-satisfied' | 'goal-not-satisfied' | 'unresolved-candidate' | 'no-candidate',
|
|
616
|
+
// with the entity ids behind each: matched, relevant, unresolved.
|
|
617
|
+
console.log(goal.id, goal.code, goal.matchedEntityIds)
|
|
618
|
+
}
|
|
619
|
+
// Whole-report notes: 'no-goals', 'unresolved-entity', 'extra-objects'.
|
|
620
|
+
console.log(checked.data.diagnostics)
|
|
621
|
+
}
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
`checked.data.feedback` is exactly what `checkGeometryAnswer()` returns, so a host can adopt the
|
|
625
|
+
report without changing what it already draws. The codes are language-neutral by design: the wording
|
|
626
|
+
shown to a student belongs to the application. `evaluateGeometryStateDetailed(exercise, geometry)` is
|
|
627
|
+
the same report for already-typed documents. Numerical deviations ("out by 3°") are not part of this
|
|
628
|
+
vocabulary.
|
|
629
|
+
|
|
630
|
+
### Starting an attempt and knowing whether it has work
|
|
631
|
+
|
|
632
|
+
Both check functions also return `fingerprints` on success: opaque strings describing the normalized
|
|
633
|
+
exercise and attempt that were graded. Keep the whole result together; the engine uses them to hide
|
|
634
|
+
feedback once the student's attempt no longer matches it. Do not parse or compare them yourself.
|
|
635
|
+
|
|
636
|
+
```ts
|
|
637
|
+
import { createEmptyGeometryAttempt, hasGeometryWork } from '@mathscan/geometry-engine/validation'
|
|
638
|
+
|
|
639
|
+
const attempt = savedAttemptJson ?? createEmptyGeometryAttempt() // a fresh object every call
|
|
640
|
+
|
|
641
|
+
// A "not started" hint, never a grade: true once the attempt holds any student entity,
|
|
642
|
+
// including a moved given; false for empty, reset or malformed input.
|
|
643
|
+
const started = hasGeometryWork(attempt)
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
`hasGeometryWork()` reads no exercise, so a stored version-1 attempt that still re-declares pinned
|
|
647
|
+
givens counts as work.
|
|
648
|
+
|
|
649
|
+
### Strict grading
|
|
650
|
+
|
|
651
|
+
By default an answer object no goal needed costs nothing: the attempt is correct if every goal is.
|
|
652
|
+
An exercise can ask for the opposite:
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
import { setExtraObjectPolicy } from '@mathscan/geometry-engine'
|
|
656
|
+
|
|
657
|
+
setExtraObjectPolicy(teacherStore, 'reject') // stores grading: { extraObjects: 'reject' }
|
|
658
|
+
setExtraObjectPolicy(teacherStore, 'allow') // removes the field again
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
The field is optional and **absence means `allow`**, so an exercise authored before this existed
|
|
662
|
+
grades as it always did. Under `reject` the engine starts from the ids in `answerEntityIds` that the
|
|
663
|
+
attempt owns, subtracts every entity that satisfies some goal and, transitively, everything those
|
|
664
|
+
entities are built on, and reports what is left as `extraEntityIds` with an `extra-objects`
|
|
665
|
+
diagnostic. A non-empty list makes the verdict `incorrect` while each goal keeps its own status —
|
|
666
|
+
"the goals are met and something unused is still on the canvas" is a distinct outcome, and a host
|
|
667
|
+
should say so rather than blame a goal.
|
|
668
|
+
|
|
669
|
+
What is never an extra: a given, an engine-invented endpoint, an implicit or promoted entity outside
|
|
670
|
+
`answerEntityIds`, and any entity a satisfied answer is built on, however deep and however the
|
|
671
|
+
student created it. What is: an entity the attempt names as an answer and nothing needed —
|
|
672
|
+
including one that no longer resolves. The policy does not count answers, so two correct segments
|
|
673
|
+
for one goal are both matches. Reveal adds the canonical answer and deletes nothing, so a strict
|
|
674
|
+
recheck after a reveal stays incorrect until the extra object is removed.
|
|
675
|
+
|
|
676
|
+
`extraEntityIds` is reported under both policies; only `reject` files the diagnostic and changes the
|
|
677
|
+
verdict.
|
|
678
|
+
|
|
679
|
+
The lower-level APIs are also available:
|
|
680
|
+
|
|
681
|
+
- `validateGeometryStateInput(geometry, exercise)` validates student data only.
|
|
682
|
+
- `validateGeometryState(exercise, geometry)` evaluates already-typed data and returns the plain result.
|
|
683
|
+
- `evaluateGeometryState(exercise, geometry)` also returns construction and point matches for visual feedback.
|
|
684
|
+
- `deriveGeometryCanvasFeedback(feedback, geometry, focusedGoalId)` converts evaluation output for a custom renderer.
|
|
685
|
+
|
|
686
|
+
### Geometric validation needs no DOM
|
|
687
|
+
|
|
688
|
+
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](#numerical-policy); intersections go to the JSXGraph kernel, which needs no board and therefore no container element.
|
|
689
|
+
|
|
690
|
+
**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](./docs/adr/0006-no-jsxgraph-board.md).
|
|
691
|
+
|
|
692
|
+
### Grading on a server
|
|
693
|
+
|
|
694
|
+
```ts
|
|
695
|
+
import { checkGeometryAnswer } from '@mathscan/geometry-engine/validation'
|
|
696
|
+
|
|
697
|
+
export function gradeAttempt(exerciseJson: unknown, attemptJson: unknown) {
|
|
698
|
+
const checked = checkGeometryAnswer(exerciseJson, attemptJson)
|
|
699
|
+
if (!checked.success) return { ok: false as const, errors: checked.errors }
|
|
700
|
+
return { ok: true as const, result: checked.data.result }
|
|
701
|
+
}
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
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.
|
|
705
|
+
|
|
706
|
+
## Display validation feedback
|
|
707
|
+
|
|
708
|
+
Pass the output of `evaluateGeometryState()` or the successful `checkGeometryAnswer()` result back to the student canvas:
|
|
709
|
+
|
|
710
|
+
```tsx
|
|
711
|
+
const checked = checkGeometryAnswer(exercise, store.getState().geometry)
|
|
712
|
+
const feedback = checked.success ? checked.data : null
|
|
713
|
+
|
|
714
|
+
<StudentGeometryCanvas
|
|
715
|
+
store={store}
|
|
716
|
+
size={{ width: 720, height: 540 }}
|
|
717
|
+
validationFeedback={feedback}
|
|
718
|
+
focusedGoalId="goal-ab"
|
|
719
|
+
/>
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
Matching constructions are displayed as correct. Relevant unsuccessful attempts can be highlighted as incorrect.
|
|
723
|
+
|
|
724
|
+
The verdict never rides on colour alone: correct work is drawn solid and
|
|
725
|
+
incorrect work dashed, and every point the check touched gains a ring around
|
|
726
|
+
it, so the three states — unchecked, correct, incorrect — differ in shape as
|
|
727
|
+
well as in colour.
|
|
728
|
+
|
|
729
|
+
## Teacher authoring
|
|
730
|
+
|
|
731
|
+
Teacher mode edits the `GeometryExercise` itself:
|
|
732
|
+
|
|
733
|
+
```tsx
|
|
734
|
+
import { createTeacherStore, GeometryCanvas } from '@mathscan/geometry-engine'
|
|
735
|
+
|
|
736
|
+
const teacherStore = createTeacherStore(exercise)
|
|
737
|
+
|
|
738
|
+
<GeometryCanvas store={teacherStore} size={{ width: 720, height: 540 }} />
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
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.
|
|
742
|
+
|
|
743
|
+
## Reveal answers
|
|
744
|
+
|
|
745
|
+
An exercise at version 4 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:
|
|
746
|
+
|
|
747
|
+
```ts
|
|
748
|
+
import { revealGeometryAttempt } from '@mathscan/geometry-engine/validation'
|
|
749
|
+
|
|
750
|
+
const revealed = revealGeometryAttempt(exerciseJson)
|
|
751
|
+
|
|
752
|
+
if (revealed.success) {
|
|
753
|
+
saveAttempt(revealed.data) // replaces the student's attempt; nothing is merged
|
|
754
|
+
} else {
|
|
755
|
+
// 'invalid-exercise' | 'invalid-canonical-attempt' | 'canonical-attempt-incorrect'
|
|
756
|
+
console.warn(revealed.errors.map((error) => error.code))
|
|
757
|
+
}
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
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.
|
|
761
|
+
|
|
762
|
+
The older, goal-synthesized path stays available for advanced callers. It generates one possible answer per goal rather than reading an authored one:
|
|
763
|
+
|
|
764
|
+
```ts
|
|
765
|
+
import { revealGeometryAnswers, revealStudentAnswer } from '@mathscan/geometry-engine'
|
|
766
|
+
|
|
767
|
+
const answers = revealGeometryAnswers(exercise)
|
|
768
|
+
const answer = answers.find((item) => item.id === 'goal-ab')
|
|
769
|
+
|
|
770
|
+
if (answer?.shape) {
|
|
771
|
+
revealStudentAnswer(store, answer.shape)
|
|
772
|
+
}
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
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`.
|
|
776
|
+
|
|
777
|
+
## Development
|
|
778
|
+
|
|
779
|
+
From a clean clone:
|
|
780
|
+
|
|
781
|
+
```sh
|
|
782
|
+
pnpm install --frozen-lockfile # exactly what CI installs
|
|
783
|
+
pnpm check # the full gate: typecheck, lint, test, format, build, verify
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
`pnpm install` (without the flag) is fine for day-to-day work; the frozen form is
|
|
787
|
+
what a fresh clone and CI use, and it fails rather than silently updating
|
|
788
|
+
`pnpm-lock.yaml`. pnpm itself is pinned by the `packageManager` field, so
|
|
789
|
+
`corepack enable` is enough to get the right version.
|
|
790
|
+
|
|
791
|
+
Launch the playground:
|
|
792
|
+
|
|
793
|
+
```sh
|
|
794
|
+
pnpm dev # dev-page playground with teacher editor + student preview
|
|
795
|
+
pnpm playground # same, opens the browser automatically
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
The `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.
|
|
799
|
+
|
|
800
|
+
Run the complete pre-publication verification (also enforced by `prepublishOnly`):
|
|
801
|
+
|
|
802
|
+
```sh
|
|
803
|
+
pnpm check
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
It ends with the four package verifiers — the export map, the API report, the bundle budgets, and a
|
|
807
|
+
consumer installed from `pnpm pack`. [docs/releasing.md](./docs/releasing.md) says what each one
|
|
808
|
+
guards and what to do when one fails.
|
|
809
|
+
|
|
810
|
+
Or run checks individually:
|
|
811
|
+
|
|
812
|
+
```sh
|
|
813
|
+
pnpm typecheck
|
|
814
|
+
pnpm lint
|
|
815
|
+
pnpm test # vitest + jsdom: schemas, stores, coordinates, geometry, snapping, state, validation
|
|
816
|
+
pnpm test:e2e # playwright + chromium and webkit: browser journeys (not part of `pnpm check`)
|
|
817
|
+
pnpm test:a11y # playwright + chromium + axe: dev-page accessibility (not part of `pnpm check`)
|
|
818
|
+
pnpm bench # performance baseline, Node (not part of `pnpm check`)
|
|
819
|
+
pnpm bench:browser # performance baseline, browser frames (not part of `pnpm check`)
|
|
820
|
+
pnpm bench:gate # the enforced performance budget (runs in CI, not in `pnpm check`)
|
|
821
|
+
pnpm format:check
|
|
822
|
+
pnpm build
|
|
823
|
+
pnpm build:dev-page
|
|
824
|
+
pnpm verify:entries # the export map and declarations, against the built output
|
|
825
|
+
pnpm verify:api # the declaration snapshot in etc/api-report.md
|
|
826
|
+
pnpm verify:size # per-entry-point bundle budgets
|
|
827
|
+
pnpm verify:package # pnpm pack, then a consumer fixture installed from the tarball
|
|
828
|
+
pnpm api:report # regenerate etc/api-report.md after an intended API change
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
The library build is generated in `dist/`. The standalone, single-file playground is generated in `dev-page-dist/index.html` and can be opened directly in a browser.
|
|
832
|
+
|
|
833
|
+
Tooling: TypeScript (strict), Vite 8 (library mode + `vite-plugin-dts`), Vitest 4, Playwright (Chromium and WebKit), ESLint 10 (typescript-eslint + react-hooks), Prettier, pnpm.
|
|
834
|
+
|
|
835
|
+
### Browser journeys
|
|
836
|
+
|
|
837
|
+
Konva hit routing, pointer capture, touch behaviour, and the snap a click
|
|
838
|
+
actually resolves to cannot be verified in jsdom — a direct-click snapping bug
|
|
839
|
+
once shipped past a fully green unit suite. `tests/e2e/` covers that gap with a
|
|
840
|
+
small set of Playwright journeys.
|
|
841
|
+
|
|
842
|
+
```sh
|
|
843
|
+
pnpm exec playwright install chromium webkit # once per machine
|
|
844
|
+
pnpm test:e2e # both engines, headless; starts its own server
|
|
845
|
+
pnpm test:e2e:critical # the @critical subset only
|
|
846
|
+
pnpm test:e2e:chromium # one engine
|
|
847
|
+
pnpm test:e2e:webkit
|
|
848
|
+
pnpm test:e2e:dev-page # the dev-page journeys only, Chromium
|
|
849
|
+
pnpm test:e2e:ui # the same journeys, in Playwright's UI
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
On Linux the browsers need system libraries as well as the download:
|
|
853
|
+
`sudo pnpm exec playwright install-deps chromium webkit`. WebKit needs
|
|
854
|
+
considerably more of them than Chromium (GTK 4, GStreamer, ICU), and without
|
|
855
|
+
them it fails at launch, not at install.
|
|
856
|
+
|
|
857
|
+
`pnpm test:e2e` starts its own Vite server on port 4176 (`vite.e2e.config.ts`)
|
|
858
|
+
serving `tests/e2e/harness/`: a minimal host that renders the shipped `Toolbar`
|
|
859
|
+
and `StudentGeometryCanvas` at a **fixed 600x600 canvas over the viewport
|
|
860
|
+
`[-1, -1, 9, 9]`** — exactly 60 pixels per math unit, so every click a journey
|
|
861
|
+
makes is an integer pixel. The exercise comes from `?scenario=<name>`
|
|
862
|
+
(`tests/e2e/harness/scenarios.ts`); an unknown name throws rather than falling
|
|
863
|
+
back. **`?canvas=WIDTHxHEIGHT`** and **`?projection=contain|stretch`** open that
|
|
864
|
+
scenario at another size or aspect ratio (M1b.4.d), and
|
|
865
|
+
`window.geometryHarness.resize()` changes it on a live page, which is what a
|
|
866
|
+
`ResizeObserver` does to a real embed. The dev page is deliberately not used: it sizes its canvas from a
|
|
867
|
+
`ResizeObserver` and takes its exercise from a textarea, neither of which a
|
|
868
|
+
pixel-exact journey can depend on.
|
|
869
|
+
|
|
870
|
+
**`?mode=teacher`** mounts the authoring half instead, as of M1b.4.c: the same
|
|
871
|
+
`Toolbar` with `GeometryCanvas` and `TeacherGeometryCompanion`, the undo, redo
|
|
872
|
+
and delete wiring, a JSON editor for the document round trip, and the switch
|
|
873
|
+
into the student preview — which is why both hosts live on one page rather than
|
|
874
|
+
two. The preview builds a student store from the exercise as it stands and is
|
|
875
|
+
torn down on the way back.
|
|
876
|
+
|
|
877
|
+
Journeys assert the resulting store and the serialized document through
|
|
878
|
+
`window.geometryHarness` (the attempt) or `window.teacherHarness` (the
|
|
879
|
+
exercise), not pixels. The teacher handle exists from load in `?mode=teacher`
|
|
880
|
+
and the student one only while the preview is open, so a journey can tell the
|
|
881
|
+
preview apart from a hidden one. DOM controls are addressed by role and
|
|
882
|
+
**exact** accessible name — role-name matching is substring-based by default,
|
|
883
|
+
which would let a renamed control keep passing — so the journeys are what pins
|
|
884
|
+
the accessible names in place. Only the stage wrapper carries a test id
|
|
885
|
+
(`data-testid="canvas"`), because a Konva canvas has no queryable DOM inside it.
|
|
886
|
+
|
|
887
|
+
**Two engines**, as of M1b.4.a: `chromium` and `webkit`. Chromium is the desktop
|
|
888
|
+
target; WebKit is the tablet one, because Safari is the only engine on iPad and a
|
|
889
|
+
tablet is the likeliest classroom device. They disagree about canvas hit routing
|
|
890
|
+
and pointer event ordering often enough that a journey passing on one proves
|
|
891
|
+
little about the other. **Every harness journey runs on both, with no
|
|
892
|
+
exceptions** since M1b.4.b. A touch drag is synthesized inside the page —
|
|
893
|
+
Playwright's `touchscreen` API offers `tap` and nothing else — and since M3.H.4
|
|
894
|
+
it is a sequence of touch `PointerEvent`s, which is all the canvases listen to;
|
|
895
|
+
`pointerSequence()` dispatches the same way for second fingers and
|
|
896
|
+
cancellations. Everything that can be native is: taps are real `tap()`s, with
|
|
897
|
+
the browser's compatibility click after them, and mouse drags are real mouse
|
|
898
|
+
input, which is the only kind a canvas can capture. The **`dev-page` project** described below drives DOM
|
|
899
|
+
chrome rather than a stage, and is Chromium-only for that reason — except for
|
|
900
|
+
**`dev-page-webkit`**, added at M3.H.3, which runs `responsive-hardening.spec.ts`
|
|
901
|
+
and nothing else: layout, activation boxes and a live rotation are exactly what
|
|
902
|
+
two engines can disagree about.
|
|
903
|
+
|
|
904
|
+
**Five device profiles**, also M3.H.3 and also on both engines. `touch-targets.spec.ts`
|
|
905
|
+
and `responsive-hardening.spec.ts` each run their whole body under a desktop
|
|
906
|
+
profile (1280x900, mouse, DPR 1), a narrow phone (360x800), a phone in landscape
|
|
907
|
+
(800x360) and both tablet orientations (768x1024, 1024x768) — the touch ones at
|
|
908
|
+
DPR 2. The profiles are set per `test.describe` with explicit `viewport`,
|
|
909
|
+
`hasTouch` and `deviceScaleFactor` rather than as ten more projects. They are
|
|
910
|
+
**emulation**: two desktop browsers pretending, which proves the measurements
|
|
911
|
+
and not the feel. No physical handset is tested, by decision.
|
|
912
|
+
|
|
913
|
+
**`@critical`** tags the journeys whose failure means a student cannot do an
|
|
914
|
+
exercise. Everything M1.4 and M1b.3 wrote carries it; broader coverage does not,
|
|
915
|
+
which is what makes the two selections differ: `pnpm test:e2e:critical` is 28
|
|
916
|
+
journeys and `pnpm test:e2e` is **391 selections** as of M3.H.4 — 167 harness
|
|
917
|
+
selections on each of two engines, 31 dev-page selections on Chromium and the 26
|
|
918
|
+
hardening selections on WebKit. The matrices are most of that growth: M3.H.3's two
|
|
919
|
+
spec files run their bodies once per device profile, and M3.H.4's
|
|
920
|
+
`gesture-cancellation.spec.ts` pins every threshold boundary per pointer type,
|
|
921
|
+
scale factor and canvas size and every cancellation trigger.
|
|
922
|
+
|
|
923
|
+
M2.9 crossed the documented ten-minute pull-request threshold at 168 selections
|
|
924
|
+
and 10.2 minutes. M3.H.4 grew the suite to **391 selections**, measured at
|
|
925
|
+
**14.4 minutes** with CI's settings (`CI=1`: two workers, one retry — none used) on
|
|
926
|
+
a development machine. Run it that way locally too: at a machine's default worker
|
|
927
|
+
count WebKit can crash mid-drag under the load, which reads as a failure and is
|
|
928
|
+
not one. Pull requests
|
|
929
|
+
therefore require the 56 critical selections across both engines; the full suite
|
|
930
|
+
runs after a push to `main`. WebKit remains roughly half again as slow per journey
|
|
931
|
+
here.
|
|
932
|
+
|
|
933
|
+
The per-test timeout is **30 seconds**, raised from 20 at M1b.4.b. A WebKit
|
|
934
|
+
_drag_ journey measures 12-17s where a click journey measures 6-7s: every pointer
|
|
935
|
+
move commits a store change and redraws the stage, and each move is its own
|
|
936
|
+
protocol round trip. The sizing journeys and the two M3.H.3 hardening
|
|
937
|
+
specs raise their own budget to 90 seconds in-file, for the same reason at a
|
|
938
|
+
larger scale: they resize a live stage repeatedly, and the whole profile matrix
|
|
939
|
+
runs in parallel workers.
|
|
940
|
+
|
|
941
|
+
The per-file exception is the visual suite's **90 seconds**: a determinism
|
|
942
|
+
journey loads the page twice and captures two full rasters, and a WebKit raster
|
|
943
|
+
is four times the pixels at that device's scale factor of 2.
|
|
944
|
+
|
|
945
|
+
**The visual suite** — `visual.spec.ts` and `canvas-size.spec.ts`, M1b.4.d —
|
|
946
|
+
asserts the raster where every other journey asserts the store, and it commits
|
|
947
|
+
**no golden images**. A raster recorded on a developer's machine and compared on
|
|
948
|
+
CI compares two font stacks and two browser builds, not two revisions of this
|
|
949
|
+
renderer: every point label is canvas text in Konva's default Arial, which
|
|
950
|
+
resolves to whatever fontconfig substitutes. It makes two machine-independent
|
|
951
|
+
claims instead:
|
|
952
|
+
|
|
953
|
+
- **Determinism** — one stable state, captured after two independent loads, is
|
|
954
|
+
the same bytes. That is the acceptance criterion the milestone states, and it
|
|
955
|
+
catches an unflushed frame, an animation, or anything else that makes a redraw
|
|
956
|
+
depend on when it ran.
|
|
957
|
+
- **Probes** — the pixel at a known math coordinate, read from the layer's own
|
|
958
|
+
2D context. This is what a golden image would have caught and a store
|
|
959
|
+
assertion cannot: that `contain` letterboxes a wide canvas and leaves the
|
|
960
|
+
bands bare, that `stretch` reaches every edge, and that a live resize really
|
|
961
|
+
redrew the figure at the new placement rather than agreeing about it in the
|
|
962
|
+
transform alone.
|
|
963
|
+
|
|
964
|
+
The **`dev-page` project** (M3.2.a) is the exception to all of the above: its
|
|
965
|
+
journeys live in `tests/dev-page/`, run on Chromium only, and drive the dev page
|
|
966
|
+
on its own Vite server on port 4177 (`vite.dev-page.config.ts`) rather than the
|
|
967
|
+
harness. The harness has no Exercise JSON panel and no demo picker, so what the
|
|
968
|
+
panel does with the store — a demo load is a load and not an edit, and a
|
|
969
|
+
keystroke after one destroys nothing — cannot be journeyed there. The engine and
|
|
970
|
+
canvas-size discussion above, and the `@critical` counts, are about the harness
|
|
971
|
+
journeys; this project adds its own selections on top of them.
|
|
972
|
+
|
|
973
|
+
`pnpm test:e2e` is not part of `pnpm check`, which must stay runnable without a
|
|
974
|
+
browser download; CI runs it as its own job.
|
|
975
|
+
|
|
976
|
+
### Accessibility checks
|
|
977
|
+
|
|
978
|
+
`pnpm test:a11y` runs [axe-core](https://github.com/dequelabs/axe-core) over the
|
|
979
|
+
dev page — the real DOM chrome — in four states: teacher mode empty, teacher
|
|
980
|
+
mode with a figure, a selection and the goal editor open, and the student
|
|
981
|
+
preview before and after checking. It also drives the student preview's
|
|
982
|
+
keyboard shortcuts (Escape, Delete, Ctrl+Z) end to end, and covers the semantic
|
|
983
|
+
companion under forced colours, at 200% zoom, with reduced motion, and through
|
|
984
|
+
Tab order.
|
|
985
|
+
|
|
986
|
+
```sh
|
|
987
|
+
pnpm exec playwright install chromium # once per machine
|
|
988
|
+
pnpm test:a11y # headless; starts its own server
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
It starts its own Vite server on port 4178 (`vite.dev-page.config.ts`) and
|
|
992
|
+
asserts axe reports **no** violations. No rule is ever disabled: a rule turned
|
|
993
|
+
off is a regression that stops being reported, so a genuine exception belongs in
|
|
994
|
+
the markup as a documented change instead.
|
|
995
|
+
|
|
996
|
+
The Konva stage is deliberately out of scope — it is one `<canvas>` element
|
|
997
|
+
with no accessible content, and nothing can give it any. The figure reaches
|
|
998
|
+
assistive technology through the **semantic companion** instead: mount
|
|
999
|
+
`StudentGeometryCompanion` or `TeacherGeometryCompanion` beside your canvas and
|
|
1000
|
+
the current tool, the pending connection, every entity as a sentence, the
|
|
1001
|
+
selection and the last verdict become ordinary DOM, derived from the same
|
|
1002
|
+
geometry context the canvas draws from. [docs/accessibility.md](./docs/accessibility.md)
|
|
1003
|
+
is the interaction design, the DOM and selector contract, and the manual test
|
|
1004
|
+
instructions (screen readers, OS high contrast, 400% zoom, reduced motion).
|
|
1005
|
+
|
|
1006
|
+
Keyboard operation of the shipped components is pinned by Journey 10 in
|
|
1007
|
+
`tests/e2e/keyboard.spec.ts`: Tab order, arming and cancelling a tool, and
|
|
1008
|
+
undo/redo/delete, all without a pointer.
|
|
1009
|
+
|
|
1010
|
+
### Performance baseline and budget
|
|
1011
|
+
|
|
1012
|
+
`pnpm bench` and `pnpm bench:browser` measure the engine over deterministic
|
|
1013
|
+
scenes of 10, 50 and 100 objects and write machine-readable JSON to
|
|
1014
|
+
`docs/performance/`. They are a **report**, not a gate, and are run by hand.
|
|
1015
|
+
`docs/performance/README.md` holds the recorded baseline, the method, the M2
|
|
1016
|
+
delta, and a deliberately provisional target device profile.
|
|
1017
|
+
|
|
1018
|
+
`pnpm bench:gate` is the **gate**, added at M1b.5, and it runs in CI on every
|
|
1019
|
+
pull request. Nothing it enforces is expressed in milliseconds: this repository's
|
|
1020
|
+
runs drift by tens of percent between sessions, so each of the twenty numbers
|
|
1021
|
+
in `docs/performance/budget.json` is a ratio between one benchmark row and a
|
|
1022
|
+
synthetic workload measured in the same run, and a slower machine slows both
|
|
1023
|
+
sides equally. See `docs/performance/README.md` § Threshold policy.
|
|
1024
|
+
|
|
1025
|
+
Two headlines are there. `computeSnap`'s pairwise intersection scan is `O(n^2)`
|
|
1026
|
+
in object count and is about 90% of what one pointer move costs at 100 objects,
|
|
1027
|
+
while the browser's per-pointer-event cost is dominated by the re-render rather
|
|
1028
|
+
than by any of the measured computation. And M2 cost about **2x on the median
|
|
1029
|
+
benchmark row** against the M1.8 baseline, which M1b.5 measured, accepted in
|
|
1030
|
+
writing and did not optimize — the rows that regressed are cheap, and the one
|
|
1031
|
+
that dominates a pointer move got faster.
|
|
1032
|
+
|
|
1033
|
+
### Continuous integration
|
|
1034
|
+
|
|
1035
|
+
`.github/workflows/ci.yml` runs on every pull request and on pushes to `main`. There are three jobs.
|
|
1036
|
+
|
|
1037
|
+
`check` runs the six commands above — typecheck, lint, unit tests, format check,
|
|
1038
|
+
library build, dev-page build — separately, so a red build names the failing gate,
|
|
1039
|
+
across Node 20.19.0, 22.12.0, and the current LTS.
|
|
1040
|
+
|
|
1041
|
+
`browser journeys` installs Chromium and WebKit. Pull requests run
|
|
1042
|
+
`pnpm test:e2e:critical` on both; pushes to `main` run the full `pnpm test:e2e`.
|
|
1043
|
+
The job then runs `pnpm test:a11y` on Chromium, on a single Node version: these
|
|
1044
|
+
exercise the browser, not the runtime, and sharing one job means installing the
|
|
1045
|
+
browsers once. The axe pass stays single-engine on purpose — it reports on the
|
|
1046
|
+
DOM a page builds, not on how an engine paints it. On failure only, the job uploads the
|
|
1047
|
+
Playwright HTML report and `test-results/` (traces, videos, screenshots) as an
|
|
1048
|
+
artifact with a 7-day retention; artifacts are written per project, so a
|
|
1049
|
+
WebKit-only failure arrives with a WebKit trace.
|
|
1050
|
+
|
|
1051
|
+
`performance gate` runs `pnpm run bench:gate` on a single Node version — the
|
|
1052
|
+
gate compares two numbers measured in the same process, so a second runtime
|
|
1053
|
+
would re-measure the same ratio. It re-runs the benchmark once before failing,
|
|
1054
|
+
so a neighbouring process on a shared runner has to slow the same row twice to
|
|
1055
|
+
turn the build red.
|
|
1056
|
+
|
|
1057
|
+
Actions are pinned to a commit SHA, and only the pnpm store is cached; build
|
|
1058
|
+
output and the browser download are never restored from a cache that could hide
|
|
1059
|
+
an error.
|
|
1060
|
+
|
|
1061
|
+
## Extending the engine
|
|
1062
|
+
|
|
1063
|
+
To add a new goal kind:
|
|
1064
|
+
|
|
1065
|
+
1. Add a schema in `src/schemas/goals/` and add it to the union in `geometryGoal.schema.ts`.
|
|
1066
|
+
2. Declare the kind in `src/schemas/goals/goalKindDeclaration.ts`: its schema, the givens it names (`referencedGivenIds`), and any cross-reference rules (`crossReferenceIssues`). `exercise.schema.ts` runs the rules, and the teacher editor reads the references when a given is deleted — neither needs an edit.
|
|
1067
|
+
3. Implement a `GoalKindHandler` in `src/validation/goals/` (`accepts`, `satisfies`, `isRelevantAttempt`, `buildRevealedAnswer`) and add it to the registry in `src/validation/goals/index.ts`.
|
|
1068
|
+
4. Cover it with tests in `tests/validation/goals/`.
|
|
1069
|
+
|
|
1070
|
+
Both registries are frozen records keyed by goal kind, so a kind missing from either is a compile error rather than a lookup that depends on which module was imported first.
|
|
1071
|
+
|
|
1072
|
+
Measurements that need real geometry (angles, intersections, incidence) go through `src/measure/geometryAdapter.ts`, keeping JSXGraph out of the handlers themselves.
|
|
1073
|
+
|
|
1074
|
+
An entity kind is the same shape of change: one `EntityOperations` entry in `src/geometry/entities/`, declaring what the kind is built on (`parents`) and what it does with those parents once they are resolved (`resolve`), plus its shape, distance and identity. The construction graph itself is kind-blind — it resolves whatever a definition names, at any depth and in any mixture of kinds — so nothing in the traversal, the deletion cascade or the issue reporting is edited to add one.
|
|
1075
|
+
|
|
1076
|
+
M2.8 added the circle as exactly that, and the full cost was: the kind in `EntityKind`, one
|
|
1077
|
+
operations file, one stored schema and its line in the v1 translation, the rows the relation tables
|
|
1078
|
+
demanded (which pairs it intersects, how it is parameterized and projected), the two kernel paths in
|
|
1079
|
+
`src/measure/`, and one shape component. Adding the kind was a **compile error** in each of those
|
|
1080
|
+
places until it was written — `EntityRegistry`, the intersection table, the parameterization and
|
|
1081
|
+
projection tables and the renderer's shape table are all mapped types over the kind union — and it
|
|
1082
|
+
was an error nowhere else. Hit testing, snapping, the graph, the indexed context, the stores and
|
|
1083
|
+
both canvases were not edited.
|
|
1084
|
+
|
|
1085
|
+
### Upgrading JSXGraph
|
|
1086
|
+
|
|
1087
|
+
`src/measure/kernel.ts` consumes JSXGraph internals — `JXG.Math.Geometry`'s meet functions and the `[c, b0, b1, a, k, r, q0, q1]` standard form they take — none of which is public, typed, or documented. The dependency is therefore pinned to an exact version.
|
|
1088
|
+
|
|
1089
|
+
**Any version bump must re-run `tests/measure/kernelContract.test.ts`**, which pins the behavior the adapter relies on: parameter conventions, the invalid `[0, 0, 0]` sentinel and how it differs from a genuine ideal point, tangency returning a duplicate point, zero-radius and non-finite handling, and operand-order effects. A failure there is the kernel changing under the engine, not the engine breaking, and the adapter has to be re-derived before the bump lands. The suite runs as part of `pnpm run check`.
|
|
1090
|
+
|
|
1091
|
+
## Known limitations
|
|
1092
|
+
|
|
1093
|
+
- Object types are limited to points, segments, lines, and circles — no arcs, angles, or polygons yet.
|
|
1094
|
+
- A circle has no tool, no goal kind and no authoring UI: it can be authored in exercise JSON and persisted in an attempt, but a student cannot draw one. Only the centre-and-through-point construction exists — no radius provenance, no three-point circle.
|
|
1095
|
+
- Goal kinds are limited to `create`, `throughPoints`, `perpendicular`, `parallel`, `segmentLength`, and `midpoint` (no angle-measure, area, or transformation goals yet).
|
|
1096
|
+
- Instruments (`ruler`, `setSquare`, `protractor`) are visual aids only: draggable pictures with no mathematical effect. The engine can neither express nor verify that a student used one.
|
|
1097
|
+
- There is no compass, so authentic Euclidean construction exercises cannot be expressed at all.
|
|
1098
|
+
- The canvas cannot be _constructed on_ from the keyboard. The chrome is fully operable and the figure is published to assistive technology through the semantic companion, but placing a point still needs a pointer.
|
|
1099
|
+
- Touch is exercised in both engines by emulated events only — never on a real device.
|
|
1100
|
+
- No minimum browser versions have been chosen. Chromium and WebKit are tested; the support floor is undecided, so the table above names engines rather than releases.
|
|
1101
|
+
- Selection still rides on Konva shape events rather than `hitTest()`, so selection tolerance is coupled to how large an object is drawn.
|