@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.
Files changed (380) hide show
  1. package/README.md +1101 -0
  2. package/dist/coordinates/grid.d.cts +8 -0
  3. package/dist/coordinates/grid.d.ts +9 -0
  4. package/dist/coordinates/grid.d.ts.map +1 -0
  5. package/dist/coordinates/lineClipping.d.cts +3 -0
  6. package/dist/coordinates/lineClipping.d.ts +4 -0
  7. package/dist/coordinates/lineClipping.d.ts.map +1 -0
  8. package/dist/coordinates/transform.d.cts +18 -0
  9. package/dist/coordinates/transform.d.ts +19 -0
  10. package/dist/coordinates/transform.d.ts.map +1 -0
  11. package/dist/core.cjs +1 -0
  12. package/dist/core.mjs +4 -0
  13. package/dist/entries/core.d.cts +16 -0
  14. package/dist/entries/core.d.ts +17 -0
  15. package/dist/entries/core.d.ts.map +1 -0
  16. package/dist/entries/react.d.cts +18 -0
  17. package/dist/entries/react.d.ts +19 -0
  18. package/dist/entries/react.d.ts.map +1 -0
  19. package/dist/entries/schemas.d.cts +14 -0
  20. package/dist/entries/schemas.d.ts +15 -0
  21. package/dist/entries/schemas.d.ts.map +1 -0
  22. package/dist/entries/validation.d.cts +12 -0
  23. package/dist/entries/validation.d.ts +13 -0
  24. package/dist/entries/validation.d.ts.map +1 -0
  25. package/dist/exercise.schema-BV2b7rtN.cjs +2 -0
  26. package/dist/exercise.schema-BV2b7rtN.cjs.map +1 -0
  27. package/dist/exercise.schema-ckVBW9XA.js +212 -0
  28. package/dist/exercise.schema-ckVBW9XA.js.map +1 -0
  29. package/dist/geometry/distance.d.cts +9 -0
  30. package/dist/geometry/distance.d.ts +10 -0
  31. package/dist/geometry/distance.d.ts.map +1 -0
  32. package/dist/geometry/entities/circle.d.cts +36 -0
  33. package/dist/geometry/entities/circle.d.ts +37 -0
  34. package/dist/geometry/entities/circle.d.ts.map +1 -0
  35. package/dist/geometry/entities/index.d.cts +60 -0
  36. package/dist/geometry/entities/index.d.ts +61 -0
  37. package/dist/geometry/entities/index.d.ts.map +1 -0
  38. package/dist/geometry/entities/line.d.cts +43 -0
  39. package/dist/geometry/entities/line.d.ts +44 -0
  40. package/dist/geometry/entities/line.d.ts.map +1 -0
  41. package/dist/geometry/entities/point.d.cts +27 -0
  42. package/dist/geometry/entities/point.d.ts +28 -0
  43. package/dist/geometry/entities/point.d.ts.map +1 -0
  44. package/dist/geometry/entities/relations.d.cts +27 -0
  45. package/dist/geometry/entities/relations.d.ts +28 -0
  46. package/dist/geometry/entities/relations.d.ts.map +1 -0
  47. package/dist/geometry/entities/segment.d.cts +45 -0
  48. package/dist/geometry/entities/segment.d.ts +46 -0
  49. package/dist/geometry/entities/segment.d.ts.map +1 -0
  50. package/dist/geometry/entityCapabilities.d.cts +2 -0
  51. package/dist/geometry/entityCapabilities.d.ts +3 -0
  52. package/dist/geometry/entityCapabilities.d.ts.map +1 -0
  53. package/dist/geometry/entityModel.d.cts +139 -0
  54. package/dist/geometry/entityModel.d.ts +140 -0
  55. package/dist/geometry/entityModel.d.ts.map +1 -0
  56. package/dist/geometry/entityRecord.d.cts +27 -0
  57. package/dist/geometry/entityRecord.d.ts +28 -0
  58. package/dist/geometry/entityRecord.d.ts.map +1 -0
  59. package/dist/geometry/evaluateGraph.d.cts +37 -0
  60. package/dist/geometry/evaluateGraph.d.ts +38 -0
  61. package/dist/geometry/evaluateGraph.d.ts.map +1 -0
  62. package/dist/geometry/geometryObject.d.cts +4 -0
  63. package/dist/geometry/geometryObject.d.ts +5 -0
  64. package/dist/geometry/geometryObject.d.ts.map +1 -0
  65. package/dist/geometry/goalCandidate.d.cts +8 -0
  66. package/dist/geometry/goalCandidate.d.ts +9 -0
  67. package/dist/geometry/goalCandidate.d.ts.map +1 -0
  68. package/dist/geometry/hitTest.d.cts +23 -0
  69. package/dist/geometry/hitTest.d.ts +24 -0
  70. package/dist/geometry/hitTest.d.ts.map +1 -0
  71. package/dist/geometry/point.d.cts +5 -0
  72. package/dist/geometry/point.d.ts +6 -0
  73. package/dist/geometry/point.d.ts.map +1 -0
  74. package/dist/geometry/pointOrigin.d.cts +5 -0
  75. package/dist/geometry/pointOrigin.d.ts +6 -0
  76. package/dist/geometry/pointOrigin.d.ts.map +1 -0
  77. package/dist/geometry/v1Bridge.d.cts +41 -0
  78. package/dist/geometry/v1Bridge.d.ts +42 -0
  79. package/dist/geometry/v1Bridge.d.ts.map +1 -0
  80. package/dist/geometryAttemptStatus-5L_q6-9l.js +184 -0
  81. package/dist/geometryAttemptStatus-5L_q6-9l.js.map +1 -0
  82. package/dist/geometryAttemptStatus-N_XHIkiS.cjs +2 -0
  83. package/dist/geometryAttemptStatus-N_XHIkiS.cjs.map +1 -0
  84. package/dist/geometryState.schema-D0ixyGYE.js +955 -0
  85. package/dist/geometryState.schema-D0ixyGYE.js.map +1 -0
  86. package/dist/geometryState.schema-DKQ-durx.cjs +2 -0
  87. package/dist/geometryState.schema-DKQ-durx.cjs.map +1 -0
  88. package/dist/goals-C3ZwbEh_.js +451 -0
  89. package/dist/goals-C3ZwbEh_.js.map +1 -0
  90. package/dist/goals-CxR5qPDC.cjs +2 -0
  91. package/dist/goals-CxR5qPDC.cjs.map +1 -0
  92. package/dist/index.cjs +1 -0
  93. package/dist/index.d.cts +4 -0
  94. package/dist/index.d.ts +5 -0
  95. package/dist/index.d.ts.map +1 -0
  96. package/dist/index.mjs +10 -0
  97. package/dist/measure/geometryAdapter.d.cts +26 -0
  98. package/dist/measure/geometryAdapter.d.ts +27 -0
  99. package/dist/measure/geometryAdapter.d.ts.map +1 -0
  100. package/dist/measure/kernel.d.cts +25 -0
  101. package/dist/measure/kernel.d.ts +26 -0
  102. package/dist/measure/kernel.d.ts.map +1 -0
  103. package/dist/numerics/policy.d.cts +17 -0
  104. package/dist/numerics/policy.d.ts +18 -0
  105. package/dist/numerics/policy.d.ts.map +1 -0
  106. package/dist/react-BEcdx1f6.js +1851 -0
  107. package/dist/react-BEcdx1f6.js.map +1 -0
  108. package/dist/react-Pp_VSk--.cjs +2 -0
  109. package/dist/react-Pp_VSk--.cjs.map +1 -0
  110. package/dist/react.cjs +1 -0
  111. package/dist/react.mjs +2 -0
  112. package/dist/renderer/GeometryCanvas.d.cts +14 -0
  113. package/dist/renderer/GeometryCanvas.d.ts +15 -0
  114. package/dist/renderer/GeometryCanvas.d.ts.map +1 -0
  115. package/dist/renderer/GeometryCompanion.d.cts +29 -0
  116. package/dist/renderer/GeometryCompanion.d.ts +30 -0
  117. package/dist/renderer/GeometryCompanion.d.ts.map +1 -0
  118. package/dist/renderer/GeometryGrid.d.cts +7 -0
  119. package/dist/renderer/GeometryGrid.d.ts +8 -0
  120. package/dist/renderer/GeometryGrid.d.ts.map +1 -0
  121. package/dist/renderer/GeometryInstruments.d.cts +7 -0
  122. package/dist/renderer/GeometryInstruments.d.ts +8 -0
  123. package/dist/renderer/GeometryInstruments.d.ts.map +1 -0
  124. package/dist/renderer/StudentGeometryCanvas.d.cts +11 -0
  125. package/dist/renderer/StudentGeometryCanvas.d.ts +12 -0
  126. package/dist/renderer/StudentGeometryCanvas.d.ts.map +1 -0
  127. package/dist/renderer/StudentGeometryExercise.d.cts +36 -0
  128. package/dist/renderer/StudentGeometryExercise.d.ts +37 -0
  129. package/dist/renderer/StudentGeometryExercise.d.ts.map +1 -0
  130. package/dist/renderer/Toolbar.d.cts +7 -0
  131. package/dist/renderer/Toolbar.d.ts +8 -0
  132. package/dist/renderer/Toolbar.d.ts.map +1 -0
  133. package/dist/renderer/deriveRenderableGeometry.d.cts +15 -0
  134. package/dist/renderer/deriveRenderableGeometry.d.ts +16 -0
  135. package/dist/renderer/deriveRenderableGeometry.d.ts.map +1 -0
  136. package/dist/renderer/deriveSemanticScene.d.cts +58 -0
  137. package/dist/renderer/deriveSemanticScene.d.ts +59 -0
  138. package/dist/renderer/deriveSemanticScene.d.ts.map +1 -0
  139. package/dist/renderer/entityDisplayName.d.cts +12 -0
  140. package/dist/renderer/entityDisplayName.d.ts +13 -0
  141. package/dist/renderer/entityDisplayName.d.ts.map +1 -0
  142. package/dist/renderer/gesturePolicy.d.cts +97 -0
  143. package/dist/renderer/gesturePolicy.d.ts +98 -0
  144. package/dist/renderer/gesturePolicy.d.ts.map +1 -0
  145. package/dist/renderer/shapes/CircleShape.d.cts +17 -0
  146. package/dist/renderer/shapes/CircleShape.d.ts +18 -0
  147. package/dist/renderer/shapes/CircleShape.d.ts.map +1 -0
  148. package/dist/renderer/shapes/EntityLabel.d.cts +14 -0
  149. package/dist/renderer/shapes/EntityLabel.d.ts +15 -0
  150. package/dist/renderer/shapes/EntityLabel.d.ts.map +1 -0
  151. package/dist/renderer/shapes/EntityShape.d.cts +18 -0
  152. package/dist/renderer/shapes/EntityShape.d.ts +19 -0
  153. package/dist/renderer/shapes/EntityShape.d.ts.map +1 -0
  154. package/dist/renderer/shapes/LineShape.d.cts +16 -0
  155. package/dist/renderer/shapes/LineShape.d.ts +17 -0
  156. package/dist/renderer/shapes/LineShape.d.ts.map +1 -0
  157. package/dist/renderer/shapes/PointShape.d.cts +17 -0
  158. package/dist/renderer/shapes/PointShape.d.ts +18 -0
  159. package/dist/renderer/shapes/PointShape.d.ts.map +1 -0
  160. package/dist/renderer/shapes/SegmentShape.d.cts +17 -0
  161. package/dist/renderer/shapes/SegmentShape.d.ts +18 -0
  162. package/dist/renderer/shapes/SegmentShape.d.ts.map +1 -0
  163. package/dist/renderer/shapes/SnapMarker.d.cts +8 -0
  164. package/dist/renderer/shapes/SnapMarker.d.ts +9 -0
  165. package/dist/renderer/shapes/SnapMarker.d.ts.map +1 -0
  166. package/dist/renderer/shapes/StrokedLine.d.cts +18 -0
  167. package/dist/renderer/shapes/StrokedLine.d.ts +19 -0
  168. package/dist/renderer/shapes/StrokedLine.d.ts.map +1 -0
  169. package/dist/renderer/studentExerciseSession.d.cts +71 -0
  170. package/dist/renderer/studentExerciseSession.d.ts +72 -0
  171. package/dist/renderer/studentExerciseSession.d.ts.map +1 -0
  172. package/dist/renderer/useConnectionPreview.d.cts +44 -0
  173. package/dist/renderer/useConnectionPreview.d.ts +45 -0
  174. package/dist/renderer/useConnectionPreview.d.ts.map +1 -0
  175. package/dist/renderer/usePlacementSnap.d.cts +28 -0
  176. package/dist/renderer/usePlacementSnap.d.ts +29 -0
  177. package/dist/renderer/usePlacementSnap.d.ts.map +1 -0
  178. package/dist/renderer/usePointerGestures.d.cts +46 -0
  179. package/dist/renderer/usePointerGestures.d.ts +47 -0
  180. package/dist/renderer/usePointerGestures.d.ts.map +1 -0
  181. package/dist/schemas/circle.schema.d.cts +8 -0
  182. package/dist/schemas/circle.schema.d.ts +9 -0
  183. package/dist/schemas/circle.schema.d.ts.map +1 -0
  184. package/dist/schemas/entityDefinition.schema.d.cts +69 -0
  185. package/dist/schemas/entityDefinition.schema.d.ts +70 -0
  186. package/dist/schemas/entityDefinition.schema.d.ts.map +1 -0
  187. package/dist/schemas/entityDocument.d.cts +95 -0
  188. package/dist/schemas/entityDocument.d.ts +96 -0
  189. package/dist/schemas/entityDocument.d.ts.map +1 -0
  190. package/dist/schemas/entityPresentation.schema.d.cts +12 -0
  191. package/dist/schemas/entityPresentation.schema.d.ts +13 -0
  192. package/dist/schemas/entityPresentation.schema.d.ts.map +1 -0
  193. package/dist/schemas/exercise.schema.d.cts +217 -0
  194. package/dist/schemas/exercise.schema.d.ts +218 -0
  195. package/dist/schemas/exercise.schema.d.ts.map +1 -0
  196. package/dist/schemas/exerciseProjection.d.cts +10 -0
  197. package/dist/schemas/exerciseProjection.d.ts +11 -0
  198. package/dist/schemas/exerciseProjection.d.ts.map +1 -0
  199. package/dist/schemas/exerciseV2.schema.d.cts +137 -0
  200. package/dist/schemas/exerciseV2.schema.d.ts +138 -0
  201. package/dist/schemas/exerciseV2.schema.d.ts.map +1 -0
  202. package/dist/schemas/exerciseV3.schema.d.cts +149 -0
  203. package/dist/schemas/exerciseV3.schema.d.ts +150 -0
  204. package/dist/schemas/exerciseV3.schema.d.ts.map +1 -0
  205. package/dist/schemas/geometryObject.schema.d.cts +17 -0
  206. package/dist/schemas/geometryObject.schema.d.ts +18 -0
  207. package/dist/schemas/geometryObject.schema.d.ts.map +1 -0
  208. package/dist/schemas/geometryState.schema.d.cts +64 -0
  209. package/dist/schemas/geometryState.schema.d.ts +65 -0
  210. package/dist/schemas/geometryState.schema.d.ts.map +1 -0
  211. package/dist/schemas/givenElement.schema.d.cts +24 -0
  212. package/dist/schemas/givenElement.schema.d.ts +25 -0
  213. package/dist/schemas/givenElement.schema.d.ts.map +1 -0
  214. package/dist/schemas/goals/createGoal.schema.d.cts +12 -0
  215. package/dist/schemas/goals/createGoal.schema.d.ts +13 -0
  216. package/dist/schemas/goals/createGoal.schema.d.ts.map +1 -0
  217. package/dist/schemas/goals/geometryGoal.schema.d.cts +58 -0
  218. package/dist/schemas/goals/geometryGoal.schema.d.ts +59 -0
  219. package/dist/schemas/goals/geometryGoal.schema.d.ts.map +1 -0
  220. package/dist/schemas/goals/goalKindDeclaration.d.cts +53 -0
  221. package/dist/schemas/goals/goalKindDeclaration.d.ts +54 -0
  222. package/dist/schemas/goals/goalKindDeclaration.d.ts.map +1 -0
  223. package/dist/schemas/goals/midpointGoal.schema.d.cts +9 -0
  224. package/dist/schemas/goals/midpointGoal.schema.d.ts +10 -0
  225. package/dist/schemas/goals/midpointGoal.schema.d.ts.map +1 -0
  226. package/dist/schemas/goals/parallelGoal.schema.d.cts +15 -0
  227. package/dist/schemas/goals/parallelGoal.schema.d.ts +16 -0
  228. package/dist/schemas/goals/parallelGoal.schema.d.ts.map +1 -0
  229. package/dist/schemas/goals/perpendicularGoal.schema.d.cts +15 -0
  230. package/dist/schemas/goals/perpendicularGoal.schema.d.ts +16 -0
  231. package/dist/schemas/goals/perpendicularGoal.schema.d.ts.map +1 -0
  232. package/dist/schemas/goals/segmentLengthGoal.schema.d.cts +9 -0
  233. package/dist/schemas/goals/segmentLengthGoal.schema.d.ts +10 -0
  234. package/dist/schemas/goals/segmentLengthGoal.schema.d.ts.map +1 -0
  235. package/dist/schemas/goals/throughPointsGoal.schema.d.cts +13 -0
  236. package/dist/schemas/goals/throughPointsGoal.schema.d.ts +14 -0
  237. package/dist/schemas/goals/throughPointsGoal.schema.d.ts.map +1 -0
  238. package/dist/schemas/identifier.schema.d.cts +2 -0
  239. package/dist/schemas/identifier.schema.d.ts +3 -0
  240. package/dist/schemas/identifier.schema.d.ts.map +1 -0
  241. package/dist/schemas/line.schema.d.cts +7 -0
  242. package/dist/schemas/line.schema.d.ts +8 -0
  243. package/dist/schemas/line.schema.d.ts.map +1 -0
  244. package/dist/schemas/migrate.d.cts +21 -0
  245. package/dist/schemas/migrate.d.ts +22 -0
  246. package/dist/schemas/migrate.d.ts.map +1 -0
  247. package/dist/schemas/objectTypeName.schema.d.cts +8 -0
  248. package/dist/schemas/objectTypeName.schema.d.ts +9 -0
  249. package/dist/schemas/objectTypeName.schema.d.ts.map +1 -0
  250. package/dist/schemas/point.schema.d.cts +15 -0
  251. package/dist/schemas/point.schema.d.ts +16 -0
  252. package/dist/schemas/point.schema.d.ts.map +1 -0
  253. package/dist/schemas/pointOrigin.schema.d.cts +25 -0
  254. package/dist/schemas/pointOrigin.schema.d.ts +26 -0
  255. package/dist/schemas/pointOrigin.schema.d.ts.map +1 -0
  256. package/dist/schemas/segment.schema.d.cts +8 -0
  257. package/dist/schemas/segment.schema.d.ts +9 -0
  258. package/dist/schemas/segment.schema.d.ts.map +1 -0
  259. package/dist/schemas/v1ToV2.d.cts +37 -0
  260. package/dist/schemas/v1ToV2.d.ts +38 -0
  261. package/dist/schemas/v1ToV2.d.ts.map +1 -0
  262. package/dist/schemas/v2ToV3.d.cts +37 -0
  263. package/dist/schemas/v2ToV3.d.ts +38 -0
  264. package/dist/schemas/v2ToV3.d.ts.map +1 -0
  265. package/dist/schemas/v3ToV4.d.cts +3 -0
  266. package/dist/schemas/v3ToV4.d.ts +4 -0
  267. package/dist/schemas/v3ToV4.d.ts.map +1 -0
  268. package/dist/schemas/viewport.schema.d.cts +3 -0
  269. package/dist/schemas/viewport.schema.d.ts +4 -0
  270. package/dist/schemas/viewport.schema.d.ts.map +1 -0
  271. package/dist/schemas.cjs +1 -0
  272. package/dist/schemas.mjs +3 -0
  273. package/dist/snapping/computeSnap.d.cts +30 -0
  274. package/dist/snapping/computeSnap.d.ts +31 -0
  275. package/dist/snapping/computeSnap.d.ts.map +1 -0
  276. package/dist/state/geometryContext.d.cts +40 -0
  277. package/dist/state/geometryContext.d.ts +41 -0
  278. package/dist/state/geometryContext.d.ts.map +1 -0
  279. package/dist/state/resolvePoints.d.cts +51 -0
  280. package/dist/state/resolvePoints.d.ts +52 -0
  281. package/dist/state/resolvePoints.d.ts.map +1 -0
  282. package/dist/store/attemptChange.d.cts +30 -0
  283. package/dist/store/attemptChange.d.ts +31 -0
  284. package/dist/store/attemptChange.d.ts.map +1 -0
  285. package/dist/store/constructionCommand.d.cts +35 -0
  286. package/dist/store/constructionCommand.d.ts +36 -0
  287. package/dist/store/constructionCommand.d.ts.map +1 -0
  288. package/dist/store/constructionIdentity.d.cts +21 -0
  289. package/dist/store/constructionIdentity.d.ts +22 -0
  290. package/dist/store/constructionIdentity.d.ts.map +1 -0
  291. package/dist/store/createStore.d.cts +6 -0
  292. package/dist/store/createStore.d.ts +7 -0
  293. package/dist/store/createStore.d.ts.map +1 -0
  294. package/dist/store/history.d.cts +2 -0
  295. package/dist/store/history.d.ts +3 -0
  296. package/dist/store/history.d.ts.map +1 -0
  297. package/dist/store/studentStore.d.cts +151 -0
  298. package/dist/store/studentStore.d.ts +152 -0
  299. package/dist/store/studentStore.d.ts.map +1 -0
  300. package/dist/store/teacherStore.d.cts +111 -0
  301. package/dist/store/teacherStore.d.ts +112 -0
  302. package/dist/store/teacherStore.d.ts.map +1 -0
  303. package/dist/store/useStoreState.d.cts +2 -0
  304. package/dist/store/useStoreState.d.ts +3 -0
  305. package/dist/store/useStoreState.d.ts.map +1 -0
  306. package/dist/studentStore-CEKTtMCh.js +1162 -0
  307. package/dist/studentStore-CEKTtMCh.js.map +1 -0
  308. package/dist/studentStore-DLftfFAl.cjs +2 -0
  309. package/dist/studentStore-DLftfFAl.cjs.map +1 -0
  310. package/dist/validation/checkGeometryAnswer.d.cts +63 -0
  311. package/dist/validation/checkGeometryAnswer.d.ts +64 -0
  312. package/dist/validation/checkGeometryAnswer.d.ts.map +1 -0
  313. package/dist/validation/deriveGeometryCanvasFeedback.d.cts +7 -0
  314. package/dist/validation/deriveGeometryCanvasFeedback.d.ts +8 -0
  315. package/dist/validation/deriveGeometryCanvasFeedback.d.ts.map +1 -0
  316. package/dist/validation/exerciseLoad.d.cts +29 -0
  317. package/dist/validation/exerciseLoad.d.ts +30 -0
  318. package/dist/validation/exerciseLoad.d.ts.map +1 -0
  319. package/dist/validation/geometryAttemptStatus.d.cts +23 -0
  320. package/dist/validation/geometryAttemptStatus.d.ts +24 -0
  321. package/dist/validation/geometryAttemptStatus.d.ts.map +1 -0
  322. package/dist/validation/getGeometryExerciseWarnings.d.cts +16 -0
  323. package/dist/validation/getGeometryExerciseWarnings.d.ts +17 -0
  324. package/dist/validation/getGeometryExerciseWarnings.d.ts.map +1 -0
  325. package/dist/validation/goals/create.d.cts +3 -0
  326. package/dist/validation/goals/create.d.ts +4 -0
  327. package/dist/validation/goals/create.d.ts.map +1 -0
  328. package/dist/validation/goals/goalContext.d.cts +44 -0
  329. package/dist/validation/goals/goalContext.d.ts +45 -0
  330. package/dist/validation/goals/goalContext.d.ts.map +1 -0
  331. package/dist/validation/goals/goalKindHandler.d.cts +26 -0
  332. package/dist/validation/goals/goalKindHandler.d.ts +27 -0
  333. package/dist/validation/goals/goalKindHandler.d.ts.map +1 -0
  334. package/dist/validation/goals/index.d.cts +14 -0
  335. package/dist/validation/goals/index.d.ts +15 -0
  336. package/dist/validation/goals/index.d.ts.map +1 -0
  337. package/dist/validation/goals/midpoint.d.cts +4 -0
  338. package/dist/validation/goals/midpoint.d.ts +5 -0
  339. package/dist/validation/goals/midpoint.d.ts.map +1 -0
  340. package/dist/validation/goals/parallel.d.cts +3 -0
  341. package/dist/validation/goals/parallel.d.ts +4 -0
  342. package/dist/validation/goals/parallel.d.ts.map +1 -0
  343. package/dist/validation/goals/perpendicular.d.cts +3 -0
  344. package/dist/validation/goals/perpendicular.d.ts +4 -0
  345. package/dist/validation/goals/perpendicular.d.ts.map +1 -0
  346. package/dist/validation/goals/previewGeometry.d.cts +9 -0
  347. package/dist/validation/goals/previewGeometry.d.ts +10 -0
  348. package/dist/validation/goals/previewGeometry.d.ts.map +1 -0
  349. package/dist/validation/goals/segmentLength.d.cts +3 -0
  350. package/dist/validation/goals/segmentLength.d.ts +4 -0
  351. package/dist/validation/goals/segmentLength.d.ts.map +1 -0
  352. package/dist/validation/goals/throughPoints.d.cts +5 -0
  353. package/dist/validation/goals/throughPoints.d.ts +6 -0
  354. package/dist/validation/goals/throughPoints.d.ts.map +1 -0
  355. package/dist/validation/gradingFingerprints.d.cts +4 -0
  356. package/dist/validation/gradingFingerprints.d.ts +5 -0
  357. package/dist/validation/gradingFingerprints.d.ts.map +1 -0
  358. package/dist/validation/revealGeometryAnswers.d.cts +3 -0
  359. package/dist/validation/revealGeometryAnswers.d.ts +4 -0
  360. package/dist/validation/revealGeometryAnswers.d.ts.map +1 -0
  361. package/dist/validation/revealGeometryAttempt.d.cts +36 -0
  362. package/dist/validation/revealGeometryAttempt.d.ts +37 -0
  363. package/dist/validation/revealGeometryAttempt.d.ts.map +1 -0
  364. package/dist/validation/types.d.cts +58 -0
  365. package/dist/validation/types.d.ts +59 -0
  366. package/dist/validation/types.d.ts.map +1 -0
  367. package/dist/validation/validateGeometryExercise.d.cts +30 -0
  368. package/dist/validation/validateGeometryExercise.d.ts +31 -0
  369. package/dist/validation/validateGeometryExercise.d.ts.map +1 -0
  370. package/dist/validation/validateGeometryState.d.cts +16 -0
  371. package/dist/validation/validateGeometryState.d.ts +17 -0
  372. package/dist/validation/validateGeometryState.d.ts.map +1 -0
  373. package/dist/validation/validateGeometryStateInput.d.cts +22 -0
  374. package/dist/validation/validateGeometryStateInput.d.ts +23 -0
  375. package/dist/validation/validateGeometryStateInput.d.ts.map +1 -0
  376. package/dist/validation.cjs +2 -0
  377. package/dist/validation.cjs.map +1 -0
  378. package/dist/validation.mjs +269 -0
  379. package/dist/validation.mjs.map +1 -0
  380. 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.