From 4acd326f8dc2f24a44c593a451e24039c61cd2bf Mon Sep 17 00:00:00 2001 From: yueye6811 Date: Sat, 8 Aug 2026 20:38:20 +0800 Subject: [PATCH 1/3] Optimize the 3D plotting path and give it axis scales 3D/contour rendering ran the AST interpreter 14,400 times per plot and allocated roughly 45 matrices per frame on the GL thread, and the plot carried no numeric scale at all -- three fixed lines that matched no data value. Dragging a contour only moved a mesh built once over the original range, so you could drag off the edge into blank space. Performance - CompiledExpr compiles the AST once into positional-slot nodes: no string parsing, no boxed map lookups, no per-call List. MathFunctions is the single source for the unary formulas so Evaluator and CompiledExpr cannot diverge; Evaluator stays as the readable reference and the parity target. - Contour marching is cell-major with level pruning: ~141,610 cell visits become ~14,161. - Mat4 works in place; the renderer builds the camera once per frame and caches the projection. Blend is enabled only around the contour heatmap, the only geometry with alpha < 1. Presentation - Contour pan/zoom is folded back into a data range on release and the mesh is re-evaluated there. 3D zoom raises grid density instead, holding the range fixed -- rotating something whose axes silently rescale is disorienting. - Both modes draw a bounding box at the data bounds with tick marks and numeric labels, sharing PlotTicks with the 2D canvas so the same function shows the same ticks in either mode. Labels render through a GL glyph atlas so they appear in the glReadPixels PNG export. Defects found in review - resample no longer shares a Job with regenerate. Tapping Plot and then dragging before the build finished cancelled the redraw on a path that never clears `loading`, stranding the progress spinner. - 2D zero/extremum labels use the new PlotTicks.formatValue (4 significant digits); routing them through the tick formatter printed a root at 0.000123 as 0.0001. Both formatters pin Locale.ROOT, which "%.4g" did not. - 3D zoom was applied twice -- model scale and camera dolly -- so a 2x pinch magnified 4x and the box's nearest corner crossed the near plane at zoom ~1.7, inside the 8x gesture range. Zoom is now applied once by narrowing the field of view with the camera fixed, and near/far come from the content radius (3.10..7.61 instead of 0.1..100). - CompiledExprTest iterated a hand-copied function-name list, so a name added to the parser was never checked. It now iterates the real MathParser.FUNCTION_NAMES. Structure - PlotGlModels.kt holds the model matrices formerly on the renderer companion; PlotGlRendererTest becomes PlotGlModelsTest. - GlyphMetrics splits the pure vertex layout out of GlyphAtlas's GL upload, which is what makes it testable at all. Note: contour segments now come out cell-major rather than level-major. They are drawn as independent GL_LINES in one colour, so the image is identical, but it is an observable array-order change. Verification: this has NOT been compiled -- there is no JDK or Android SDK on this machine, so CI is the first compiler. Node cross-checks confirm the new projection magnifies linearly in zoom (2.8e-16 deviation) and that no point at the content radius crosses near or far at any zoom, that the contour range inverse round-trips to 1.7e-8 px, and that in-place translate and scale are bit-identical to the allocating originals. The new unit tests are the real gate. Known limitation: in portrait the horizontal field of view contains only radius ~1.0-1.2 at zoom 1 while the axis geometry reaches 2.03, so corner labels can sit off-screen. Pre-existing framing behaviour, but the new labels make it matter; fixing it trades plot size for label visibility and needs a separate decision. Co-Authored-By: Claude Opus 5 --- README.md | 3 +- README.zh-CN.md | 2 +- .../paruh/maxmath/ui/plot/GlGestureMath.kt | 32 + .../paruh/maxmath/ui/plot/Plot2DPainter.kt | 44 +- .../com/paruh/maxmath/ui/plot/PlotTicks.kt | 130 ++++ .../paruh/maxmath/ui/plot/PlotViewModel.kt | 60 +- .../paruh/maxmath/ui/plot/gl/GlyphAtlas.kt | 132 ++++ .../paruh/maxmath/ui/plot/gl/GlyphMetrics.kt | 103 +++ .../java/com/paruh/maxmath/ui/plot/gl/Mat4.kt | 335 ++++++---- .../paruh/maxmath/ui/plot/gl/PlotGlAxes.kt | 163 +++++ .../paruh/maxmath/ui/plot/gl/PlotGlMesh.kt | 96 ++- .../paruh/maxmath/ui/plot/gl/PlotGlModels.kt | 68 ++ .../maxmath/ui/plot/gl/PlotGlRenderer.kt | 597 +++++++++++++----- .../paruh/maxmath/ui/plot/gl/PlotGlView.kt | 17 + .../paruh/maxmath/ui/screens/PlotScreen.kt | 50 +- .../com/paruh/maxmath/ui/theme/PlotColors.kt | 6 + .../maxmath/ui/plot/GlGestureMathTest.kt | 56 ++ .../paruh/maxmath/ui/plot/PlotTicksTest.kt | 167 +++++ .../maxmath/ui/plot/PlotViewModelTest.kt | 109 ++++ .../maxmath/ui/plot/gl/GlyphMetricsTest.kt | 111 ++++ .../com/paruh/maxmath/ui/plot/gl/Mat4Test.kt | 95 +++ .../maxmath/ui/plot/gl/PlotGlAxesTest.kt | 146 +++++ .../maxmath/ui/plot/gl/PlotGlMeshTest.kt | 42 ++ ...tGlRendererTest.kt => PlotGlModelsTest.kt} | 11 +- docs/OPTIMIZATION.md | 7 +- docs/SPEC.md | 13 +- .../com/paruh/maxmath/parser/CompiledExpr.kt | 208 ++++++ .../com/paruh/maxmath/parser/Evaluator.kt | 67 +- .../com/paruh/maxmath/parser/MathFunctions.kt | 64 ++ .../com/paruh/maxmath/parser/MathParser.kt | 7 +- .../paruh/maxmath/parser/CompiledExprTest.kt | 183 ++++++ 31 files changed, 2727 insertions(+), 397 deletions(-) create mode 100644 app/src/main/java/com/paruh/maxmath/ui/plot/PlotTicks.kt create mode 100644 app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphAtlas.kt create mode 100644 app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphMetrics.kt create mode 100644 app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlAxes.kt create mode 100644 app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlModels.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/plot/PlotTicksTest.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/GlyphMetricsTest.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlAxesTest.kt rename app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/{PlotGlRendererTest.kt => PlotGlModelsTest.kt} (86%) create mode 100644 parser/src/main/kotlin/com/paruh/maxmath/parser/CompiledExpr.kt create mode 100644 parser/src/main/kotlin/com/paruh/maxmath/parser/MathFunctions.kt create mode 100644 parser/src/test/kotlin/com/paruh/maxmath/parser/CompiledExprTest.kt diff --git a/README.md b/README.md index e1492ca..54772fa 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ symbolic computations run in a separate engine process. - Vector spaces: inner product, norm, and Gram-Schmidt orthogonalization - Quadratic forms: expansion, eigenvalues, and signature - Calculus: limits, derivatives of arbitrary order, and definite or indefinite integrals -- Plotting: touch-enabled 2D multi-function plots, 3D surfaces, and contour plots +- Plotting: touch-enabled 2D multi-function plots, 3D surfaces, and contour plots, + all with axis ticks and numeric labels - Input: implicit multiplication, radicals, fractions, common functions, π/e, and natural equation syntax - Output: offline LaTeX rendering, copyable text, and PNG saving or sharing - Localization: Chinese, English, or the system language diff --git a/README.zh-CN.md b/README.zh-CN.md index a74552c..49f3cb2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,7 +27,7 @@ Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,复杂 - 向量空间:内积、范数、Gram-Schmidt 正交化 - 二次型:展开、特征值与符号差 - 微积分:极限、任意阶导数、定积分与不定积分 -- 绘图:2D 多函数图像、3D 曲面和等高线,支持触控交互 +- 绘图:2D 多函数图像、3D 曲面和等高线,带坐标轴刻度与数值标签,支持触控交互 - 输入:隐式乘法、根号、分数、常用函数、π/e 与自然写法方程组 - 输出:离线 LaTeX 渲染,可复制文本,并可保存或分享 PNG - 本地化:中文、English、跟随系统 diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt index 6cbd400..6d05be7 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt @@ -26,4 +26,36 @@ object GlGestureMath { panY = state.panY - pan.y / imgH * 2f, zoom = (state.zoom * zoom).coerceIn(MIN_ZOOM, MAX_ZOOM), ) + + /** + * 等高线:把当前 pan/zoom 折算回数据视口,供手势结束后按新范围重新采样。 + * + * 没有这一步,拖动只是在平移一张已经画好的图——拖出原范围就是空白, + * 放大看到的也只是被放大的网格单元。这与 2D 那条路径的做法一致 + * ([PlotGestureMath.transform] 做的是同一件事,只是它在位图空间里算)。 + * + * 数据点 v 的归一化坐标是 `n = 2*(v - center)/extent`,边框占据 n∈[-1,1]; + * 视图又对它做了 `scale(zoom)` 再 `translate(pan)`。反解出此刻落在 + * 边框位置上的那段数据区间即可,随后把视图变换归位([resetView]), + * 边框就回到原处,画面不跳。 + */ + fun contourRange(base: PlotRange, state: GlViewState): PlotRange { + val zoom = state.zoom.coerceAtLeast(MIN_ZOOM) + val halfWidth = base.width / 2.0 + val halfHeight = base.height / 2.0 + val nxMin = (-1f - state.panX) / zoom + val nxMax = (1f - state.panX) / zoom + val nyMin = (-1f - state.panY) / zoom + val nyMax = (1f - state.panY) / zoom + return PlotRange( + xMin = base.centerX + nxMin * halfWidth, + xMax = base.centerX + nxMax * halfWidth, + yMin = base.centerY + nyMin * halfHeight, + yMax = base.centerY + nyMax * halfHeight, + ) + } + + /** 视口已经并进数据范围之后,把视图变换归位。 */ + fun resetView(state: GlViewState): GlViewState = + state.copy(panX = 0f, panY = 0f, zoom = 1f) } diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt index 76299c9..5763ab3 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt @@ -15,10 +15,6 @@ import com.paruh.maxmath.engine.PlotAnnotations import com.paruh.maxmath.parser.Evaluator import com.paruh.maxmath.parser.Expr import com.paruh.maxmath.ui.theme.PlotPalette -import kotlin.math.ceil -import kotlin.math.floor -import kotlin.math.log10 -import kotlin.math.pow /** * 2D 实时绘图画布:网格、坐标轴、刻度、曲线、零点/极值标注。 @@ -144,7 +140,14 @@ object Plot2DPainter { ann.zeros.forEach { x -> if (x in range.xMin..range.xMax) { canvas.drawCircle(Offset(sx(x), sy(0.0)), 4f, markerPaint) - drawLabel(canvas, "(${fmt(x)}, 0)", sx(x), sy(0.0) - 6f, textSizePx, palette.zeroArgb) + drawLabel( + canvas, + "(${PlotTicks.formatValue(x)}, 0)", + sx(x), + sy(0.0) - 6f, + textSizePx, + palette.zeroArgb, + ) } } markerPaint.color = palette.extrema @@ -161,7 +164,7 @@ object Plot2DPainter { canvas.drawPath(diamondPath, markerPaint) drawLabel( canvas, - "(${fmt(x)}, ${fmt(y)})", + "(${PlotTicks.formatValue(x)}, ${PlotTicks.formatValue(y)})", cx, cy - 10f, textSizePx, @@ -214,6 +217,8 @@ object Plot2DPainter { /** * 刻度值与其格式化标签。两者只随坐标范围变化,纯平移/缩放的中间帧 * 可以直接复用,避免每帧重算刻度并对每个标签做一次 String.format。 + * + * 刻度值本身由 [PlotTicks] 算——3D/等高线的坐标轴用的是同一份。 */ private class TickCache { var values: DoubleArray = DoubleArray(0) @@ -231,29 +236,15 @@ object Plot2DPainter { cachedMin = min cachedMax = max count = 0 - val span = max - min - if (span <= 0.0) return - val raw = span / 8.0 - val exp = floor(log10(raw)) - val fraction = raw / 10.0.pow(exp) - val step = when { - fraction <= 1.0 -> 1.0 - fraction <= 2.0 -> 2.0 - fraction <= 5.0 -> 5.0 - else -> 10.0 - } * 10.0.pow(exp) - // 上界按 step 估算容量,避免边界浮点误差导致越界。 - val capacity = ((span / step).toInt() + 3).coerceAtLeast(1) + val capacity = PlotTicks.capacity(min, max, PlotTicks.TARGET_2D) + if (capacity == 0) return if (values.size < capacity) { values = DoubleArray(capacity) labels = Array(capacity) { "" } } - var v = ceil(min / step) * step - while (v <= max + step * 1e-9 && count < capacity) { - values[count] = v - labels[count] = fmt(v) - count++ - v += step + count = PlotTicks.into(values, min, max, PlotTicks.TARGET_2D) + for (i in 0 until count) { + labels[i] = PlotTicks.format(values[i]) } } } @@ -293,6 +284,3 @@ object Plot2DPainter { canvas.nativeCanvas.drawText(text, x - textPaint.measureText(text) / 2f, y, textPaint) } } - -/** 顶层私有:嵌套的 TickCache 也要用,放在 object 内会引入作用域歧义。 */ -private fun fmt(v: Double): String = "%.4g".format(v) diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/PlotTicks.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/PlotTicks.kt new file mode 100644 index 0000000..3e781d8 --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/PlotTicks.kt @@ -0,0 +1,130 @@ +package com.paruh.maxmath.ui.plot + +import java.util.Locale +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.pow + +/** + * 刻度值与其标签的唯一来源:2D 画布([Plot2DPainter])与 3D/等高线坐标轴 + * ([com.paruh.maxmath.ui.plot.gl.PlotGlAxes])共用。 + * + * 分成两份实现的话,同一个 x 范围在 2D 与等高线下会给出两组不同的刻度, + * 而这两种模式画的本来就是同一个函数——用户切一下模式就会看到刻度跳变。 + * + * 纯函数,无状态,可在 JVM 单测。 + */ +object PlotTicks { + + /** 2D 画布的目标刻度数。 */ + const val TARGET_2D = 8 + + /** 3D/等高线坐标轴的目标刻度数:三条轴同时带标签,密了会糊成一片。 */ + const val TARGET_GL = 6 + + /** + * 1-2-5 步长:把 `span / target` 向上取整到 1、2、5 或 10 乘以 10 的幂。 + * 范围非正时返回 0。 + */ + fun step(span: Double, target: Int): Double { + if (!(span > 0.0) || target <= 0) return 0.0 + val raw = span / target + val exp = floor(log10(raw)) + val fraction = raw / 10.0.pow(exp) + val mantissa = when { + fraction <= 1.0 -> 1.0 + fraction <= 2.0 -> 2.0 + fraction <= 5.0 -> 5.0 + else -> 10.0 + } + return mantissa * 10.0.pow(exp) + } + + /** + * [min]..[max] 内刻度数的上界。按步长估算并留出余量, + * 让调用方一次分配到位——边界上的浮点误差不该导致越界。 + */ + fun capacity(min: Double, max: Double, target: Int): Int { + val span = max - min + val step = step(span, target) + if (step <= 0.0) return 0 + return ((span / step).toInt() + 3).coerceAtLeast(1) + } + + /** + * 把 [min]..[max] 内的刻度写进 [dest],返回写入个数。 + * [dest] 不够长时写满为止——用 [capacity] 分配就不会发生。 + */ + fun into(dest: DoubleArray, min: Double, max: Double, target: Int): Int { + val step = step(max - min, target) + if (step <= 0.0) return 0 + var count = 0 + var v = ceil(min / step) * step + // 容差按步长取相对值:绝对值大时 v 的末位误差也按比例放大。 + val limit = max + step * 1e-9 + while (v <= limit && count < dest.size) { + dest[count] = v + count++ + v += step + } + return count + } + + /** + * 刻度标签。刻度值都是 1-2-5 的「整」数,所以定点输出后把尾随的零去掉: + * 轴上写「2」而不是「2.000」,省下的横向空间在 3D 里尤其值钱。 + * + * 固定精度的四舍五入顺带吸收了 `ceil(min/step)*step` 带来的末位噪声 + * (0.30000000000000004 → "0.3")。 + * + * 显式用 [Locale.ROOT]:数字标签在任何界面语言下都该是小数点, + * 跟随默认区域会在德语等语言下变成逗号。 + */ + fun format(v: Double): String { + if (!v.isFinite()) return "" + if (v == 0.0) return "0" + val magnitude = abs(v) + if (magnitude >= FIXED_MAX || magnitude < FIXED_MIN) return compactExponent(v) + return trimTrailingZeros("%.4f".format(Locale.ROOT, v)) + } + + /** + * 任意数值的标签:零点、极值这类坐标,不是刻度。 + * + * 和 [format] 分开是因为两者要的东西不同。刻度值是 1-2-5 的整数,固定四位 + * 小数够用;而零点可能落在 0.000123 这种地方,固定四位小数会把它压成 + * 「0.0001」——只剩一位有效数字,标注也就没了意义。这里按量级选小数位, + * 保住四位有效数字。 + * + * 区域同样固定为 [Locale.ROOT]:坐标里的小数点不该随界面语言变成逗号。 + */ + fun formatValue(v: Double): String { + if (!v.isFinite()) return "" + if (v == 0.0) return "0" + val magnitude = abs(v) + if (magnitude >= FIXED_MAX || magnitude < FIXED_MIN) return compactExponent(v) + // 四位有效数字所需的小数位;|v| ≥ 1e4 时取 0,直接输出整数部分。 + val decimals = (3 - floor(log10(magnitude)).toInt()).coerceIn(0, 12) + return trimTrailingZeros("%.${decimals}f".format(Locale.ROOT, v)) + } + + /** 定点输出的量级区间,两个格式化函数共用。 */ + private const val FIXED_MIN = 1e-4 + private const val FIXED_MAX = 1e5 + + /** `1.000e+05` → `1e5`,`3.000e-07` → `3e-7`:轴上一个字符都不浪费。 */ + private fun compactExponent(v: Double): String { + val raw = "%.3e".format(Locale.ROOT, v) + val e = raw.indexOf('e') + val mantissa = trimTrailingZeros(raw.substring(0, e)) + val rawExponent = raw.substring(e + 1) + val negative = rawExponent.startsWith("-") + val digits = rawExponent.trimStart('+', '-').trimStart('0').ifEmpty { "0" } + return mantissa + "e" + (if (negative) "-" else "") + digits + } + + private fun trimTrailingZeros(s: String): String = + if (s.contains('.')) s.trimEnd('0').trimEnd('.') else s +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt index 95390a6..ff8ac6b 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt @@ -63,8 +63,27 @@ class PlotViewModel( private var job: Job? = null + /** + * 手势结束后的重采样任务,与 [job] 分开。 + * + * 合用一个字段会出两个问题:重采样取消掉正在跑的 [regenerate] 之后没人再把 + * loading 置回 false,进度条就永远转下去;而且用户刚点的「绘图」会被一次拖动 + * 悄悄吃掉。分开之后 loading 只由 [regenerate] 持有,这两件事都不会发生。 + */ + private var resampleJob: Job? = null + + /** + * 上一次 GL 绘图用的表达式与模式。[resample] 靠它们在手势结束后重建网格, + * 不必再走一遍 [PlotTask] 的解析与校验——那些输入框此刻可能已经被改过了, + * 重新读会拿到用户还没点「绘图」的内容。 + */ + private var glExpr: Expr? = null + private var glKind: PlotKind? = null + fun regenerate(task: PlotTask) { job?.cancel() + // 迟到的重采样会把用户刚输入的范围盖回旧值。 + resampleJob?.cancel() if (task.kind == PlotKind.PLOT_2D) { MaximaEngine.cancel() } @@ -83,6 +102,7 @@ class PlotViewModel( /** 与 CalcViewModel.cancel 一致:撤销请求,不清屏。 */ fun cancel() { job?.cancel() + resampleJob?.cancel() MaximaEngine.cancel() _state.update { it.copy(loading = false) } } @@ -141,16 +161,46 @@ class PlotViewModel( ) return } + glExpr = expr + glKind = task.kind val mesh = withContext(ioDispatcher) { - if (task.kind == PlotKind.PLOT_3D) { - PlotGlMesh.buildSurface(expr, range.xMin, range.xMax, range.yMin, range.yMax) - } else { - PlotGlMesh.buildContour(expr, range.xMin, range.xMax, range.yMin, range.yMax) - } + buildMesh(expr, task.kind, range, PlotGlMesh.DEFAULT_GRID) } _state.value = PlotUiState(loading = false, glMesh = mesh, glRange = range, range = range) } + /** + * 按新视口/新网格密度重建 3D 或等高线网格,供手势结束后调用。 + * + * 与 [regenerate] 有三处刻意的不同: + * - 不置 loading,也不碰 [job]。网格构建现在只有几毫秒,每次松手闪一下 + * 进度条纯属噪声;新网格到达之前旧的一直留在屏幕上。 + * - 用 copy 而不是整体赋值一个新 PlotUiState。整体赋值是给 2D↔3D 切换 + * 丢弃另一种模式残留产物用的,这里模式没变,丢掉 functionsAst 之类 + * 反而会出问题。 + * - 完整重绘正在跑时直接放弃本次重采样。它读的是用户刚在输入框里敲的范围, + * 比从手势反推出来的更权威,而且马上就会整体替换掉当前状态。 + */ + fun resample(range: PlotRange, grid: Int) { + val expr = glExpr ?: return + val kind = glKind ?: return + if (job?.isActive == true) return + resampleJob?.cancel() + resampleJob = viewModelScope.launch { + val mesh = withContext(ioDispatcher) { buildMesh(expr, kind, range, grid) } + _state.update { + it.copy(error = null, glMesh = mesh, glRange = range, range = range) + } + } + } + + private fun buildMesh(expr: Expr, kind: PlotKind, range: PlotRange, grid: Int): GlMesh = + if (kind == PlotKind.PLOT_3D) { + PlotGlMesh.buildSurface(expr, range.xMin, range.xMax, range.yMin, range.yMax, grid) + } else { + PlotGlMesh.buildContour(expr, range.xMin, range.xMax, range.yMin, range.yMax, grid) + } + private fun parseRange(task: PlotTask): Result = runCatching { fun number(raw: String, invalidRes: Int): Double = raw.trim().toDoubleOrNull() diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphAtlas.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphAtlas.kt new file mode 100644 index 0000000..65d62a4 --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphAtlas.kt @@ -0,0 +1,132 @@ +package com.paruh.maxmath.ui.plot.gl + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.opengl.GLES20 +import android.opengl.GLUtils + +/** + * 坐标轴数值标签用的字形图集:一张纹理,加上排版用的 [metrics]。 + * + * 走 GL 而不是在 GLSurfaceView 上盖一层 Compose Canvas,是因为「保存 PNG」 + * 用 glReadPixels 抓帧缓冲:Compose 覆盖层不在帧缓冲里,导出的图会没有数字, + * 屏幕上看到的和存下来的对不上。 + * + * 字形一次性画进一张位图并传成纹理,之后每个字符只是一个贴图四边形。 + * 图集尺寸向上取到 2 的幂并生成 mipmap:标签在透视下会被缩小, + * 没有 mipmap 的缩小采样会让数字闪烁。 + * + * 这个类只管纹理的生成与释放;把标签排成顶点的那段纯计算在 [GlyphMetrics] 里, + * 那部分不碰 GL,可以单测。 + */ +internal class GlyphAtlas private constructor( + val texture: Int, + val metrics: GlyphMetrics, +) { + + /** 释放纹理。必须在 GL 线程调用。 */ + fun release() { + if (texture != 0) { + GLES20.glDeleteTextures(1, intArrayOf(texture), 0) + } + } + + companion object { + + /** + * 按 [textSizePx] 生成图集并上传纹理。必须在 GL 线程调用。 + * 纹理创建失败时返回 null,调用方据此跳过标签绘制。 + */ + fun create(textSizePx: Float): GlyphAtlas? { + val chars = GlyphMetrics.CHARS + val padding = GlyphMetrics.PADDING + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = textSizePx + color = Color.WHITE + } + val fm = paint.fontMetrics + val cellHeight = padding * 2f + (fm.bottom - fm.top) + val n = chars.length + val advance = FloatArray(n) + val cellWidth = FloatArray(n) + val cellStart = FloatArray(n) + var x = 0f + val single = CharArray(1) + for (i in 0 until n) { + single[0] = chars[i] + advance[i] = paint.measureText(single, 0, 1) + cellWidth[i] = advance[i] + padding * 2f + cellStart[i] = x + x += cellWidth[i] + } + + val atlasWidth = nextPowerOfTwo(kotlin.math.ceil(x).toInt().coerceAtLeast(1)) + val atlasHeight = nextPowerOfTwo(kotlin.math.ceil(cellHeight).toInt().coerceAtLeast(1)) + val bitmap = Bitmap.createBitmap(atlasWidth, atlasHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val baseline = padding - fm.top + for (i in 0 until n) { + single[0] = chars[i] + canvas.drawText(single, 0, 1, cellStart[i] + padding, baseline, paint) + } + + val ids = IntArray(1) + GLES20.glGenTextures(1, ids, 0) + if (ids[0] == 0) { + bitmap.recycle() + return null + } + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, ids[0]) + GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0) + GLES20.glGenerateMipmap(GLES20.GL_TEXTURE_2D) + GLES20.glTexParameteri( + GLES20.GL_TEXTURE_2D, + GLES20.GL_TEXTURE_MIN_FILTER, + GLES20.GL_LINEAR_MIPMAP_LINEAR, + ) + GLES20.glTexParameteri( + GLES20.GL_TEXTURE_2D, + GLES20.GL_TEXTURE_MAG_FILTER, + GLES20.GL_LINEAR, + ) + GLES20.glTexParameteri( + GLES20.GL_TEXTURE_2D, + GLES20.GL_TEXTURE_WRAP_S, + GLES20.GL_CLAMP_TO_EDGE, + ) + GLES20.glTexParameteri( + GLES20.GL_TEXTURE_2D, + GLES20.GL_TEXTURE_WRAP_T, + GLES20.GL_CLAMP_TO_EDGE, + ) + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0) + bitmap.recycle() + + val u0 = FloatArray(n) + val u1 = FloatArray(n) + for (i in 0 until n) { + u0[i] = cellStart[i] / atlasWidth + u1[i] = (cellStart[i] + cellWidth[i]) / atlasWidth + } + return GlyphAtlas( + texture = ids[0], + metrics = GlyphMetrics( + advance = advance, + cellWidth = cellWidth, + u0 = u0, + u1 = u1, + cellHeight = cellHeight, + v1 = cellHeight / atlasHeight, + ), + ) + } + + private fun nextPowerOfTwo(v: Int): Int { + var p = 1 + while (p < v) p = p shl 1 + return p + } + } +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphMetrics.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphMetrics.kt new file mode 100644 index 0000000..82ab8b6 --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphMetrics.kt @@ -0,0 +1,103 @@ +package com.paruh.maxmath.ui.plot.gl + +/** + * 字形排版数据,以及把标签排成顶点数组的那段纯计算。 + * + * 从 [GlyphAtlas] 里拆出来:原先字体度量、位图绘制、纹理上传和顶点排版挤在 + * 一个类里,而排版是这里面唯一有可能算错、又完全不需要 GL 的部分——四个下标、 + * 六个顶点、居中偏移,错一个字就歪一片。混在一起的结果是它一行测试都没有。 + * 现在这个类可以直接用合成的度量数组构造出来,在 JVM 上测。 + * + * 单位:[advance]、[cellWidth]、[cellHeight] 是像素;[u0]、[u1]、[v1] 是纹理坐标。 + */ +internal class GlyphMetrics( + private val advance: FloatArray, + private val cellWidth: FloatArray, + private val u0: FloatArray, + private val u1: FloatArray, + private val cellHeight: Float, + private val v1: Float, +) { + + /** + * 把 [labels] 编译成可直接绘制的顶点数组。 + * + * 每个字形 6 个顶点(两个三角形),每个顶点 [FLOATS_PER_VERTEX] 个浮点: + * `ax, ay, az` 是标签在归一化空间的锚点(一个标签的所有顶点共用), + * `ox, oy` 是相对锚点的**像素**偏移(y 向上),`u, v` 是图集纹理坐标。 + * 顶点着色器把锚点投影到裁剪空间后再按像素偏移平移,标签因此始终正对 + * 屏幕、大小恒定——见 PlotGlRenderer.LABEL_VERTEX_SHADER。 + * + * 文本以锚点为中心:横向按总宽居中,纵向按字形盒居中。 + * 图集里没有的字符直接跳过,不占顶点。 + */ + fun buildVertices(labels: List): FloatArray { + var glyphs = 0 + for (label in labels) { + for (c in label.text) if (indexOf(c) >= 0) glyphs++ + } + if (glyphs == 0) return FloatArray(0) + + val out = FloatArray(glyphs * VERTICES_PER_GLYPH * FLOATS_PER_VERTEX) + val top = cellHeight / 2f + val bottom = -cellHeight / 2f + var at = 0 + for (label in labels) { + var totalWidth = 0f + for (c in label.text) { + val i = indexOf(c) + if (i >= 0) totalWidth += advance[i] + } + var pen = -totalWidth / 2f + for (c in label.text) { + val i = indexOf(c) + if (i < 0) continue + // 字形在图集里带一圈透明边距,四边形要连边距一起画出来, + // 否则线性采样会把相邻字形的边缘拖进来。 + val left = pen - PADDING + val right = left + cellWidth[i] + at = putVertex(out, at, label, left, top, u0[i], 0f) + at = putVertex(out, at, label, left, bottom, u0[i], v1) + at = putVertex(out, at, label, right, bottom, u1[i], v1) + at = putVertex(out, at, label, left, top, u0[i], 0f) + at = putVertex(out, at, label, right, bottom, u1[i], v1) + at = putVertex(out, at, label, right, top, u1[i], 0f) + pen += advance[i] + } + } + return out + } + + private fun putVertex( + out: FloatArray, + at: Int, + label: AxisLabel, + ox: Float, + oy: Float, + u: Float, + v: Float, + ): Int { + out[at] = label.x + out[at + 1] = label.y + out[at + 2] = label.z + out[at + 3] = ox + out[at + 4] = oy + out[at + 5] = u + out[at + 6] = v + return at + FLOATS_PER_VERTEX + } + + private fun indexOf(c: Char): Int = CHARS.indexOf(c) + + companion object { + + const val FLOATS_PER_VERTEX = 7 + const val VERTICES_PER_GLYPH = 6 + + /** 数值标签只会用到这些字符:数字、小数点、正负号、指数与轴名。 */ + const val CHARS = "0123456789.-+exyz" + + /** 字形四周的透明边距(像素),防止线性采样串味。 */ + const val PADDING = 2f + } +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/Mat4.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/Mat4.kt index bb3039e..53018c9 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/Mat4.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/Mat4.kt @@ -7,21 +7,47 @@ import kotlin.math.tan /** * 最小 4x4 列主序矩阵(OpenGL 约定),仅实现渲染器所需操作。 * 纯 Kotlin 实现以便在 JVM 上单测,不依赖 android.opengl.Matrix。 + * + * 手势期间每帧都要重算一整套矩阵,所以这里的每个操作都是**原地**的: + * [multiply] 复用实例自带的暂存数组,[translate]/[scale] 直接改对应的列而不是 + * 现造一个矩阵去乘。[Companion] 里带 `set` 前缀的构造器同样写进已有实例, + * 分配版本([rotationY]、[perspective] …)只是它们的一层薄封装, + * 公式仍然只有一份。 + * + * 与既有代码一样**不是线程安全的**:一个实例只应由一个线程使用 + * (渲染器的暂存矩阵归 GL 线程)。 */ class Mat4 private constructor( val m: FloatArray = FloatArray(16), ) { + /** + * [multiply] 的暂存区。惰性分配:一次性矩阵(`rotationY(...)` 之类) + * 不必为一个可能永远用不上的数组付钱,而渲染器长期持有的暂存矩阵 + * 只在第一帧分配一次。 + */ + private var product: FloatArray? = null + fun set(other: Mat4): Mat4 { other.m.copyInto(m) return this } + /** 单位矩阵,原地写入。 */ + fun setIdentity(): Mat4 { + m.fill(0f) + m[0] = 1f + m[5] = 1f + m[10] = 1f + m[15] = 1f + return this + } + /** this = this * other,先应用 other 再应用 this。 */ fun multiply(other: Mat4): Mat4 { val a = m val b = other.m - val out = FloatArray(16) + val out = product ?: FloatArray(16).also { product = it } for (c in 0 until 4) { for (r in 0 until 4) { var v = 0f @@ -31,19 +57,179 @@ class Mat4 private constructor( out[c * 4 + r] = v } } + // 先算进 out 再拷回,所以 a.multiply(a) 这样的自乘也是对的。 out.copyInto(m) return this } - fun translate(x: Float, y: Float, z: Float): Mat4 = multiply(translation(x, y, z)) + /** + * this = this * T(x, y, z)。右乘平移只影响第 3 列,直接算这一列即可, + * 不必构造一个平移矩阵再走一遍通用乘法。 + */ + fun translate(x: Float, y: Float, z: Float): Mat4 { + for (r in 0 until 4) { + m[12 + r] = m[r] * x + m[4 + r] * y + m[8 + r] * z + m[12 + r] + } + return this + } fun rotateX(radians: Float): Mat4 = multiply(rotationX(radians)) fun rotateY(radians: Float): Mat4 = multiply(rotationY(radians)) - fun scale(s: Float): Mat4 = multiply(scaling(s)) + fun scale(s: Float): Mat4 = scale(s, s, s) + + /** this = this * S(x, y, z)。右乘缩放只是给前三列各乘一个系数。 */ + fun scale(x: Float, y: Float, z: Float): Mat4 { + for (r in 0 until 4) { + m[r] *= x + m[4 + r] *= y + m[8 + r] *= z + } + return this + } + + /** 绕 X 轴旋转,原地写入。 */ + fun setRotationX(radians: Float): Mat4 { + val c = cos(radians) + val s = sin(radians) + m.fill(0f) + m[0] = 1f + m[5] = c + m[6] = s + m[9] = -s + m[10] = c + m[15] = 1f + return this + } + + /** 绕 Y 轴旋转,原地写入。 */ + fun setRotationY(radians: Float): Mat4 { + val c = cos(radians) + val s = sin(radians) + m.fill(0f) + m[0] = c + m[2] = -s + m[5] = 1f + m[8] = s + m[10] = c + m[15] = 1f + return this + } + + /** 绕 Z 轴旋转,原地写入。 */ + fun setRotationZ(radians: Float): Mat4 { + val c = cos(radians) + val s = sin(radians) + m.fill(0f) + m[0] = c + m[1] = s + m[4] = -s + m[5] = c + m[10] = 1f + m[15] = 1f + return this + } + + /** 平移矩阵,原地写入。 */ + fun setTranslation(x: Float, y: Float, z: Float): Mat4 { + setIdentity() + m[12] = x + m[13] = y + m[14] = z + return this + } + + /** 缩放矩阵,原地写入。 */ + fun setScaling(x: Float, y: Float, z: Float): Mat4 { + m.fill(0f) + m[0] = x + m[5] = y + m[10] = z + m[15] = 1f + return this + } + + /** 透视投影,原地写入。参数含义见 [Companion.perspective]。 */ + fun setPerspective(fovy: Float, aspect: Float, near: Float, far: Float): Mat4 { + val f = 1f / tan(fovy / 2f) + val range = near - far + m.fill(0f) + m[0] = f / aspect + m[5] = f + m[10] = (far + near) / range + m[11] = -1f + m[14] = 2f * far * near / range + return this + } + + /** 正交投影,原地写入。参数含义见 [Companion.ortho]。 */ + fun setOrtho( + left: Float, + right: Float, + bottom: Float, + top: Float, + near: Float, + far: Float, + ): Mat4 { + val rl = right - left + val tb = top - bottom + val fn = far - near + m.fill(0f) + m[0] = 2f / rl + m[5] = 2f / tb + m[10] = -2f / fn + m[12] = -(right + left) / rl + m[13] = -(top + bottom) / tb + m[14] = -(far + near) / fn + m[15] = 1f + return this + } + + /** 视图矩阵,原地写入。参数含义见 [Companion.lookAt]。 */ + fun setLookAt( + eyeX: Float, eyeY: Float, eyeZ: Float, + centerX: Float, centerY: Float, centerZ: Float, + upX: Float, upY: Float, upZ: Float, + ): Mat4 { + var fX = centerX - eyeX + var fY = centerY - eyeY + var fZ = centerZ - eyeZ + val fLen = length(fX, fY, fZ) + fX /= fLen + fY /= fLen + fZ /= fLen - fun scale(x: Float, y: Float, z: Float): Mat4 = multiply(scaling(x, y, z)) + var sX = fY * upZ - fZ * upY + var sY = fZ * upX - fX * upZ + var sZ = fX * upY - fY * upX + val sLen = length(sX, sY, sZ) + sX /= sLen + sY /= sLen + sZ /= sLen + + val uX = sY * fZ - sZ * fY + val uY = sZ * fX - sX * fZ + val uZ = sX * fY - sY * fX + + m[0] = sX + m[1] = uX + m[2] = -fX + m[3] = 0f + m[4] = sY + m[5] = uY + m[6] = -fY + m[7] = 0f + m[8] = sZ + m[9] = uZ + m[10] = -fZ + m[11] = 0f + m[12] = -(sX * eyeX + sY * eyeY + sZ * eyeZ) + m[13] = -(uX * eyeX + uY * eyeY + uZ * eyeZ) + m[14] = fX * eyeX + fY * eyeY + fZ * eyeZ + m[15] = 1f + return this + } companion object { @@ -57,81 +243,46 @@ class Mat4 private constructor( scaleX: Float, scaleY: Float, scaleZ: Float, - ): FloatArray = floatArrayOf( - rot.m[0] * scaleX, rot.m[1] * scaleX, rot.m[2] * scaleX, - rot.m[4] * scaleY, rot.m[5] * scaleY, rot.m[6] * scaleY, - rot.m[8] * scaleZ, rot.m[9] * scaleZ, rot.m[10] * scaleZ, - ) - - fun identity(): Mat4 = Mat4().also { - it.m[0] = 1f - it.m[5] = 1f - it.m[10] = 1f - it.m[15] = 1f - } + ): FloatArray = normalMatrixInto(FloatArray(9), rot, scaleX, scaleY, scaleZ) - fun translation(x: Float, y: Float, z: Float): Mat4 = identity().also { - it.m[12] = x - it.m[13] = y - it.m[14] = z + /** [normalMatrix] 的原地版本,供逐帧复用同一个 [dest]。 */ + fun normalMatrixInto( + dest: FloatArray, + rot: Mat4, + scaleX: Float, + scaleY: Float, + scaleZ: Float, + ): FloatArray { + val r = rot.m + dest[0] = r[0] * scaleX + dest[1] = r[1] * scaleX + dest[2] = r[2] * scaleX + dest[3] = r[4] * scaleY + dest[4] = r[5] * scaleY + dest[5] = r[6] * scaleY + dest[6] = r[8] * scaleZ + dest[7] = r[9] * scaleZ + dest[8] = r[10] * scaleZ + return dest } - fun scaling(s: Float): Mat4 = identity().also { - it.m[0] = s - it.m[5] = s - it.m[10] = s - } + fun identity(): Mat4 = Mat4().setIdentity() - fun scaling(x: Float, y: Float, z: Float): Mat4 = identity().also { - it.m[0] = x - it.m[5] = y - it.m[10] = z - } + fun translation(x: Float, y: Float, z: Float): Mat4 = Mat4().setTranslation(x, y, z) - fun rotationX(radians: Float): Mat4 { - val c = cos(radians) - val s = sin(radians) - return Mat4(floatArrayOf( - 1f, 0f, 0f, 0f, - 0f, c, s, 0f, - 0f, -s, c, 0f, - 0f, 0f, 0f, 1f, - )) - } + fun scaling(s: Float): Mat4 = Mat4().setScaling(s, s, s) - fun rotationY(radians: Float): Mat4 { - val c = cos(radians) - val s = sin(radians) - return Mat4(floatArrayOf( - c, 0f, -s, 0f, - 0f, 1f, 0f, 0f, - s, 0f, c, 0f, - 0f, 0f, 0f, 1f, - )) - } + fun scaling(x: Float, y: Float, z: Float): Mat4 = Mat4().setScaling(x, y, z) - fun rotationZ(radians: Float): Mat4 { - val c = cos(radians) - val s = sin(radians) - return Mat4(floatArrayOf( - c, s, 0f, 0f, - -s, c, 0f, 0f, - 0f, 0f, 1f, 0f, - 0f, 0f, 0f, 1f, - )) - } + fun rotationX(radians: Float): Mat4 = Mat4().setRotationX(radians) + + fun rotationY(radians: Float): Mat4 = Mat4().setRotationY(radians) + + fun rotationZ(radians: Float): Mat4 = Mat4().setRotationZ(radians) /** 透视投影:fovy 弧度、宽高比、近/远平面。 */ - fun perspective(fovy: Float, aspect: Float, near: Float, far: Float): Mat4 { - val f = 1f / tan(fovy / 2f) - val range = near - far - return Mat4(floatArrayOf( - f / aspect, 0f, 0f, 0f, - 0f, f, 0f, 0f, - 0f, 0f, (far + near) / range, -1f, - 0f, 0f, 2f * far * near / range, 0f, - )) - } + fun perspective(fovy: Float, aspect: Float, near: Float, far: Float): Mat4 = + Mat4().setPerspective(fovy, aspect, near, far) /** 正交投影:left/right/bottom/top 为近平面的左右上下,near/far 为深度。 */ fun ortho( @@ -141,17 +292,7 @@ class Mat4 private constructor( top: Float, near: Float, far: Float, - ): Mat4 { - val rl = right - left - val tb = top - bottom - val fn = far - near - return Mat4(floatArrayOf( - 2f / rl, 0f, 0f, 0f, - 0f, 2f / tb, 0f, 0f, - 0f, 0f, -2f / fn, 0f, - -(right + left) / rl, -(top + bottom) / tb, -(far + near) / fn, 1f, - )) - } + ): Mat4 = Mat4().setOrtho(left, right, bottom, top, near, far) /** * 视图矩阵:相机位于 eye,看向 center,up 为上方向。 @@ -161,37 +302,7 @@ class Mat4 private constructor( eyeX: Float, eyeY: Float, eyeZ: Float, centerX: Float, centerY: Float, centerZ: Float, upX: Float, upY: Float, upZ: Float, - ): Mat4 { - var fX = centerX - eyeX - var fY = centerY - eyeY - var fZ = centerZ - eyeZ - val fLen = length(fX, fY, fZ) - fX /= fLen - fY /= fLen - fZ /= fLen - - var sX = fY * upZ - fZ * upY - var sY = fZ * upX - fX * upZ - var sZ = fX * upY - fY * upX - val sLen = length(sX, sY, sZ) - sX /= sLen - sY /= sLen - sZ /= sLen - - val uX = sY * fZ - sZ * fY - val uY = sZ * fX - sX * fZ - val uZ = sX * fY - sY * fX - - return Mat4(floatArrayOf( - sX, uX, -fX, 0f, - sY, uY, -fY, 0f, - sZ, uZ, -fZ, 0f, - -(sX * eyeX + sY * eyeY + sZ * eyeZ), - -(uX * eyeX + uY * eyeY + uZ * eyeZ), - fX * eyeX + fY * eyeY + fZ * eyeZ, - 1f, - )) - } + ): Mat4 = Mat4().setLookAt(eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ) private fun length(x: Float, y: Float, z: Float): Float { val len = kotlin.math.sqrt(x * x + y * y + z * z) diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlAxes.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlAxes.kt new file mode 100644 index 0000000..301ba70 --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlAxes.kt @@ -0,0 +1,163 @@ +package com.paruh.maxmath.ui.plot.gl + +import com.paruh.maxmath.ui.plot.PlotRange +import com.paruh.maxmath.ui.plot.PlotTicks + +/** 一条数值标签:文本 + 它在归一化空间里的锚点,渲染时以锚点为中心贴屏。 */ +data class AxisLabel( + val text: String, + val x: Float, + val y: Float, + val z: Float, +) + +/** + * 坐标轴几何。顶点都在**归一化空间**(数据范围映射到 [-1,1]), + * 与 [PlotGlRenderer] 画覆盖层时用的 model 矩阵(不含 normalize 那一步)一致。 + */ +class GlAxes( + /** 三条主轴各 2 个顶点;等高线没有 z 轴,对应数组为空。 */ + val axisX: FloatArray, + val axisY: FloatArray, + val axisZ: FloatArray, + /** 其余边框线与全部刻度短线,同色绘制。 */ + val frame: FloatArray, + val labels: List, +) + +/** + * 由数据范围生成 3D/等高线的坐标轴:包围盒、主轴、刻度短线与数值标签。 + * + * 原先这里只有三条从 -1.2 到 1.2 的固定直线,既不表示数据边界也没有任何 + * 数值——看到一个曲面却无从知道它横跨的是 -5..5 还是 -0.5..0.5。现在盒子 + * **就是**数据边界,刻度落在 [PlotTicks] 给出的整值上,与 2D 模式同一套算法, + * 所以同一个函数在两种模式下的刻度是对得上的。 + * + * 纯计算、不依赖 GL,可在 JVM 单测。只在网格上传时跑,不在逐帧路径上。 + */ +object PlotGlAxes { + + /** 刻度短线长度(归一化单位)。 */ + const val TICK = 0.05f + + /** 数值标签锚点相对刻度末端再向外推的距离。 */ + private const val LABEL_GAP = 0.09f + + /** 轴名(x/y/z)锚点向外推的距离,要躲开数值标签。 */ + private const val NAME_GAP = 0.30f + + /** z 轴刻度朝盒外的对角方向伸,免得和 x、y 的标签在角上叠一起。 */ + private const val DIAGONAL = 0.70710678f + + private val EXTREMES = floatArrayOf(-1f, 1f) + + fun build(range: PlotRange, zMin: Float, zMax: Float, kind: GlPlotKind): GlAxes { + val surface = kind == GlPlotKind.SURFACE + // 曲面的轴画在包围盒底面,等高线是俯视平面图,一切都在 z=0。 + val floor = if (surface) -1f else 0f + + val frame = LineBuilder() + val labels = ArrayList(32) + + val axisX = floatArrayOf(-1f, -1f, floor, 1f, -1f, floor) + val axisY = floatArrayOf(-1f, -1f, floor, -1f, 1f, floor) + val axisZ = if (surface) floatArrayOf(-1f, -1f, -1f, -1f, -1f, 1f) else FloatArray(0) + + if (surface) { + boxEdges(frame) + } else { + // 等高线只补上边与右边——下边、左边已经是两条主轴。 + frame.add(-1f, 1f, floor, 1f, 1f, floor) + frame.add(1f, -1f, floor, 1f, 1f, floor) + } + + // x 刻度:沿 x 棱,朝 -y 伸。 + forEachTick(range.xMin, range.xMax) { value, n -> + frame.add(n, -1f, floor, n, -1f - TICK, floor) + labels += AxisLabel(PlotTicks.format(value), n, -1f - TICK - LABEL_GAP, floor) + } + labels += AxisLabel("x", 1f, -1f - TICK - NAME_GAP, floor) + + // y 刻度:沿 y 棱,朝 -x 伸。 + forEachTick(range.yMin, range.yMax) { value, n -> + frame.add(-1f, n, floor, -1f - TICK, n, floor) + labels += AxisLabel(PlotTicks.format(value), -1f - TICK - LABEL_GAP, n, floor) + } + labels += AxisLabel("y", -1f - TICK - NAME_GAP, 1f, floor) + + // z 刻度:只有曲面有。z 的范围来自网格实际取到的函数值,不是用户输入。 + if (surface) { + val stub = TICK * DIAGONAL + val anchor = (TICK + LABEL_GAP) * DIAGONAL + forEachTick(zMin.toDouble(), zMax.toDouble()) { value, n -> + frame.add(-1f, -1f, n, -1f - stub, -1f - stub, n) + labels += AxisLabel(PlotTicks.format(value), -1f - anchor, -1f - anchor, n) + } + val nameOut = (TICK + NAME_GAP) * DIAGONAL + labels += AxisLabel("z", -1f - nameOut, -1f - nameOut, 1f) + } + + return GlAxes(axisX, axisY, axisZ, frame.build(), labels) + } + + /** + * 遍历 [min]..[max] 上的刻度,回调收到数据值与它的归一化坐标。 + * + * 贴在两端的刻度会被跳过:它们正好压在包围盒的棱上,标签也会和轴名打架。 + */ + private inline fun forEachTick(min: Double, max: Double, emit: (value: Double, n: Float) -> Unit) { + val extent = max - min + if (!(extent > 0.0)) return + val capacity = PlotTicks.capacity(min, max, PlotTicks.TARGET_GL) + if (capacity == 0) return + val values = DoubleArray(capacity) + val count = PlotTicks.into(values, min, max, PlotTicks.TARGET_GL) + val center = (min + max) / 2.0 + for (i in 0 until count) { + val n = (2.0 * (values[i] - center) / extent).toFloat() + if (n <= -0.999f || n >= 0.999f) continue + emit(values[i], n) + } + } + + /** 包围盒 12 条棱里除去三条主轴的那 9 条。 */ + private fun boxEdges(out: LineBuilder) { + for (y in EXTREMES) { + for (z in EXTREMES) { + if (y == -1f && z == -1f) continue // x 主轴 + out.add(-1f, y, z, 1f, y, z) + } + } + for (x in EXTREMES) { + for (z in EXTREMES) { + if (x == -1f && z == -1f) continue // y 主轴 + out.add(x, -1f, z, x, 1f, z) + } + } + for (x in EXTREMES) { + for (y in EXTREMES) { + if (x == -1f && y == -1f) continue // z 主轴 + out.add(x, y, -1f, x, y, 1f) + } + } + } + + /** 可增长的线段缓冲。用 ArrayList 会把每个坐标都装箱。 */ + private class LineBuilder { + private var data = FloatArray(256) + private var count = 0 + + fun add(x0: Float, y0: Float, z0: Float, x1: Float, y1: Float, z1: Float) { + if (count + 6 > data.size) data = data.copyOf(data.size * 2) + data[count] = x0 + data[count + 1] = y0 + data[count + 2] = z0 + data[count + 3] = x1 + data[count + 4] = y1 + data[count + 5] = z1 + count += 6 + } + + fun build(): FloatArray = if (count == data.size) data else data.copyOf(count) + } +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlMesh.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlMesh.kt index 03eefbb..70c3c5d 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlMesh.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlMesh.kt @@ -1,7 +1,9 @@ package com.paruh.maxmath.ui.plot.gl -import com.paruh.maxmath.parser.Evaluator +import com.paruh.maxmath.parser.CompiledExpr import com.paruh.maxmath.parser.Expr +import kotlin.math.ceil +import kotlin.math.floor import kotlin.math.sqrt /** @@ -26,7 +28,23 @@ data class GlMesh( object PlotGlMesh { - private const val DEFAULT_GRID = 120 + const val DEFAULT_GRID = 120 + + /** GL ES 2.0 用 unsigned short 索引,n×n 顶点要求 n² ≤ 65535。 */ + const val MAX_GRID = 255 + + /** 槽位顺序:slots[0] = x,slots[1] = y。 */ + private val XY_VARS = listOf("x", "y") + + /** + * 按缩放倍数挑网格密度。 + * + * 曲面放大时数据范围不变(转着看的东西再改范围会让人失去方位感), + * 所以「更多细节」只能来自更密的网格——否则放大后看到的只是被拉大的 + * 多边形。按 √zoom 增长:屏幕上的三角形边长大致维持不变。 + */ + fun gridFor(zoom: Float): Int = + (DEFAULT_GRID * sqrt(zoom.coerceAtLeast(1f))).toInt().coerceIn(DEFAULT_GRID, MAX_GRID) fun buildSurface( expr: Expr, @@ -36,7 +54,7 @@ object PlotGlMesh { yMax: Double, grid: Int = DEFAULT_GRID, ): GlMesh { - require(grid in 2..255) { "grid 必须在 2..255 之间(GL ES 2.0 索引上限 65535)" } + require(grid in 2..MAX_GRID) { "grid 必须在 2..$MAX_GRID 之间(GL ES 2.0 索引上限 65535)" } val (xs, ys, zs) = evaluateGrid(expr, xMin, xMax, yMin, yMax, grid) val n = grid val positions = FloatArray(n * n * 3) @@ -112,7 +130,7 @@ object PlotGlMesh { grid: Int = DEFAULT_GRID, levels: Int = 10, ): GlMesh { - require(grid in 2..255) { "grid 必须在 2..255 之间(GL ES 2.0 索引上限 65535)" } + require(grid in 2..MAX_GRID) { "grid 必须在 2..$MAX_GRID 之间(GL ES 2.0 索引上限 65535)" } val (xs, ys, zs) = evaluateGrid(expr, xMin, xMax, yMin, yMax, grid) val n = grid val positions = FloatArray(n * n * 3) @@ -168,6 +186,11 @@ object PlotGlMesh { private fun allFinite(zs: FloatArray, a: Int, b: Int, c: Int, d: Int): Boolean = zs[a].isFinite() && zs[b].isFinite() && zs[c].isFinite() && zs[d].isFinite() + /** + * 在 n×n 网格上求值。表达式先 [CompiledExpr.compile] 一次,之后每个采样点 + * 只有 double 运算:走 `Evaluator` 的话,每个点都要重新解析每个数字字面量 + * 并对 x、y 各做一次装箱的 HashMap 查找,而这里默认要跑 14400 个点。 + */ private fun evaluateGrid( expr: Expr, xMin: Double, @@ -179,12 +202,13 @@ object PlotGlMesh { val xs = linspace(xMin, xMax, n) val ys = linspace(yMin, yMax, n) val zs = FloatArray(n * n) - val vars = HashMap() + val compiled = CompiledExpr.compile(expr, XY_VARS) + val slots = compiled.newSlots() for (i in 0 until n) { - vars["x"] = xs[i] + slots[0] = xs[i] for (j in 0 until n) { - vars["y"] = ys[j] - zs[i * n + j] = Evaluator.eval(expr, vars).toFloat() + slots[1] = ys[j] + zs[i * n + j] = compiled.eval(slots).toFloat() } } return Triple(xs, ys, zs) @@ -258,6 +282,17 @@ object PlotGlMesh { normals[v * 3 + 2] += nz } + /** + * Marching squares。**按单元遍历,等值线在内层**——反过来(原实现)意味着 + * 默认 10 条等值线要把 14161 个单元各走 10 遍,每遍重读四个角、重做一次 + * 有限性判断,一共 141610 次。 + * + * 单元一次读齐四角后,只有落在 [cellMin, cellMax] 之间的等值线才可能穿过 + * 它,典型单元因此只需试 1~2 条而不是全部 10 条。 + * + * 与原实现相比线段的**输出顺序**从「按层」变成「按单元」。它们是各自独立、 + * 同色的 GL_LINES,画面完全相同。 + */ private fun buildContourLines( xs: DoubleArray, ys: DoubleArray, @@ -268,24 +303,43 @@ object PlotGlMesh { levelCount: Int, ): FloatArray { if (zMax <= zMin || levelCount <= 0) return FloatArray(0) + val divisions = levelCount + 1 + val levels = FloatArray(levelCount) { zMin + (zMax - zMin) * (it + 1) / divisions } + // 等值线等距,所以「z 值 → 等值线下标」是一次乘法。 + val toLevelIndex = divisions.toDouble() / (zMax - zMin).toDouble() + var lines = FloatArray(1024) var count = 0 // 一个单元最多 4 个交点,每个交点存 (x, y)。 val hits = DoubleArray(8) - for (k in 0 until levelCount) { - val level = zMin + (zMax - zMin) * (k + 1) / (levelCount + 1) - for (i in 0 until n - 1) { - for (j in 0 until n - 1) { - val a = i * n + j - val b = a + 1 - val c = a + n + 1 - val d = a + n - if (!allFinite(zs, a, b, c, d)) continue + for (i in 0 until n - 1) { + for (j in 0 until n - 1) { + val a = i * n + j + val b = a + 1 + val c = a + n + 1 + val d = a + n + val za = zs[a] + val zb = zs[b] + val zc = zs[c] + val zd = zs[d] + if (!za.isFinite() || !zb.isFinite() || !zc.isFinite() || !zd.isFinite()) continue + val cellMin = minOf(minOf(za, zb), minOf(zc, zd)) + val cellMax = maxOf(maxOf(za, zb), maxOf(zc, zd)) + + // 下标区间两端各放宽一格,格内再用 [cellMin, cellMax] 精确判断: + // 区间只用来剪枝,判定权在精确比较手里,浮点舍入漏不掉线段。 + val kLo = (ceil((cellMin - zMin).toDouble() * toLevelIndex).toInt() - 2) + .coerceAtLeast(0) + val kHi = floor((cellMax - zMin).toDouble() * toLevelIndex).toInt() + .coerceAtMost(levelCount - 1) + for (k in kLo..kHi) { + val level = levels[k] + if (level < cellMin || level > cellMax) continue var hitCount = 0 - hitCount = addEdgeHit(hits, hitCount, zs[a], zs[b], xs[i], ys[j], xs[i + 1], ys[j], level) - hitCount = addEdgeHit(hits, hitCount, zs[b], zs[c], xs[i + 1], ys[j], xs[i + 1], ys[j + 1], level) - hitCount = addEdgeHit(hits, hitCount, zs[c], zs[d], xs[i + 1], ys[j + 1], xs[i], ys[j + 1], level) - hitCount = addEdgeHit(hits, hitCount, zs[d], zs[a], xs[i], ys[j + 1], xs[i], ys[j], level) + hitCount = addEdgeHit(hits, hitCount, za, zb, xs[i], ys[j], xs[i + 1], ys[j], level) + hitCount = addEdgeHit(hits, hitCount, zb, zc, xs[i + 1], ys[j], xs[i + 1], ys[j + 1], level) + hitCount = addEdgeHit(hits, hitCount, zc, zd, xs[i + 1], ys[j + 1], xs[i], ys[j + 1], level) + hitCount = addEdgeHit(hits, hitCount, zd, za, xs[i], ys[j + 1], xs[i], ys[j], level) var p = 0 while (p + 1 < hitCount) { if (count + 6 > lines.size) lines = lines.copyOf(lines.size * 2) diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlModels.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlModels.kt new file mode 100644 index 0000000..0708c4f --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlModels.kt @@ -0,0 +1,68 @@ +package com.paruh.maxmath.ui.plot.gl + +import com.paruh.maxmath.ui.plot.PlotRange + +/** + * 各类几何的模型矩阵。 + * + * 从 [PlotGlRenderer] 里拆出来:这些是纯 [Mat4] 运算,一句 GL 调用都没有, + * 原先挂在渲染器的伴生对象上只是为了让单测够得着。渲染器那个文件已经同时管着 + * 着色器、相机、三条绘制路径、图集与缓冲区生命周期,把能独立测的部分留在里面 + * 只会让它更难拆。 + * + * 每个矩阵都有一个分配版和一个原地版:渲染器逐帧调用原地版复用同一块 + * [Mat4](GL 线程上一次 GC 就是一帧掉帧),分配版转调原地版, + * 于是测试测到的始终是渲染时真正跑的那份实现。 + */ +internal object PlotGlModels { + + /** + * 曲面网格归一化:数据坐标 → [-1,1]^3,中心平移到原点。 + * 必须先平移再缩放(S*T),否则中心不会落在原点。 + */ + fun surfaceNormalize(range: PlotRange, zMin: Float, zMax: Float): Mat4 = + surfaceNormalizeInto(Mat4.identity(), range, zMin, zMax) + + /** [surfaceNormalize] 的原地版本:渲染器逐帧复用同一个 [dest]。 */ + fun surfaceNormalizeInto(dest: Mat4, range: PlotRange, zMin: Float, zMax: Float): Mat4 { + val ex = (range.xMax - range.xMin).toFloat().coerceAtLeast(1e-6f) + val ey = (range.yMax - range.yMin).toFloat().coerceAtLeast(1e-6f) + val ez = (zMax - zMin).coerceAtLeast(1e-6f) + return dest.setIdentity() + .scale(2f / ex, 2f / ey, 2f / ez) + .translate(-range.centerX.toFloat(), -range.centerY.toFloat(), -(zMin + zMax) / 2f) + } + + /** + * 等高线边框模型:顶点已是归一化 [-1,1] 坐标,只应用 pan/zoom。 + * + * 这里的 scale(zoom) 是对的,与曲面不同:等高线是正交投影,缩放没有别处 + * 可以承担。曲面的缩放由视场角承担,模型里不再乘一次——见 + * [PlotGlRenderer] 的 updateCamera。 + */ + fun contourFrameModel(state: GlViewState): Mat4 = + contourFrameModelInto(Mat4.identity(), state) + + /** [contourFrameModel] 的原地版本。 */ + fun contourFrameModelInto(dest: Mat4, state: GlViewState): Mat4 = + dest.setIdentity() + .translate(state.panX, state.panY, 0f) + .scale(state.zoom) + + /** + * 等高线等值线模型:顶点是数据坐标,需先归一化(与热力图 mesh 完全一致)。 + */ + fun contourLineModel(state: GlViewState, range: PlotRange): Mat4 = + contourLineModelInto(Mat4.identity(), state, range) + + /** [contourLineModel] 的原地版本。 */ + fun contourLineModelInto(dest: Mat4, state: GlViewState, range: PlotRange): Mat4 { + val ex = (range.xMax - range.xMin).toFloat().coerceAtLeast(1e-6f) + val ey = (range.yMax - range.yMin).toFloat().coerceAtLeast(1e-6f) + // 原式是 frame * (scale * translate);矩阵乘法结合律让它等于 + // ((frame * scale) * translate),于是可以一路原地做完。 + return contourFrameModelInto(dest, state) + .scale(2f / ex, 2f / ey, 1f) + .translate(-range.centerX.toFloat(), -range.centerY.toFloat(), 0f) + } +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt index d2aaee6..26fceec 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt @@ -8,7 +8,10 @@ import com.paruh.maxmath.ui.plot.PlotRange import com.paruh.maxmath.ui.theme.GlPalette import java.nio.ByteBuffer import java.nio.ByteOrder +import kotlin.math.atan +import kotlin.math.sin import kotlin.math.sqrt +import kotlin.math.tan enum class GlPlotKind { SURFACE, CONTOUR } @@ -25,6 +28,11 @@ data class GlViewState( /** * OpenGL ES 2.0 渲染器:绘制 3D 曲面(逐顶点光照)与等高线热力图 * (等值线覆盖)。渲染只在手势状态/网格变化时触发(RENDERMODE_WHEN_DIRTY)。 + * + * 手势期间每一帧都会走一遍这里,所以 [onDrawFrame] 这条路径上**不分配**: + * 所有矩阵都是构造一次的字段,逐帧原地重写(见 [Mat4])。相机(旋转、视图、 + * 投影)每帧只算一次,曲面与坐标轴共用——原先两边各算一遍,且各自读一次 + * [state],手势正好落在两者之间时还会画出网格与坐标轴对不上的一帧。 */ internal class PlotGlRenderer : GLSurfaceView.Renderer { @@ -62,10 +70,32 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { private var contourBuf = 0 private var contourSize = 0 + /** + * 标签字号(像素)。由 [com.paruh.maxmath.ui.plot.gl.PlotGlController] 按 + * 屏幕密度推下来;变了就要重建图集与标签顶点,因为字形度量变了。 + */ + @Volatile + var labelTextPx: Float = DEFAULT_LABEL_PX + private var axisXBuf = 0 private var axisYBuf = 0 private var axisZBuf = 0 private var frameBuf = 0 + private var axisXCount = 0 + private var axisYCount = 0 + private var axisZCount = 0 + private var frameCount = 0 + + private var labelBuf = 0 + private var labelVertexCount = 0 + private var atlas: GlyphAtlas? = null + + /** 图集当前是按哪个字号建的;-1 表示还没建过(见 [ensureAtlas])。 */ + private var atlasTextPx = -1f + + /** 坐标轴几何随网格、范围与模式变化,任一变了就重建。 */ + private var axesKind: GlPlotKind? = null + private var axesDirty = true private var captureCallback: ((Bitmap) -> Unit)? = null @@ -81,6 +111,37 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { // glGetUniformLocation/glGetAttribLocation,那是同步的驱动查询。 private val axesMvpUniform = intArrayOf(0) private val axesPosAttr = intArrayOf(0) + private var labelProgram = 0 + private val labelAnchorAttr = intArrayOf(0) + private val labelOffsetAttr = intArrayOf(0) + private val labelUvAttr = intArrayOf(0) + private val labelMvpUniform = intArrayOf(0) + private val labelViewportUniform = intArrayOf(0) + private val labelColorUniform = intArrayOf(0) + private val labelTexUniform = intArrayOf(0) + + // 逐帧复用的矩阵,只属于 GL 线程。 + private val rotYM = Mat4.identity() + private val rotXM = Mat4.identity() + private val rotM = Mat4.identity() + private val viewM = Mat4.identity() + private val projM = Mat4.identity() + private val modelM = Mat4.identity() + private val mvpM = Mat4.identity() + private val normalizeM = Mat4.identity() + private val normalMat = FloatArray(9) + private val deleteIds = IntArray(5) + + /** [projM] 只跟 kind 与宽高比有关,变了才重算。 */ + private var projKind: GlPlotKind? = null + private var projWidth = 0 + private var projHeight = 0 + + /** + * 曲面的视场角随 zoom 变,投影矩阵因此也要跟着重算。 + * 忘了把 zoom 放进缓存键的话,捏合会完全没有反应。 + */ + private var projZoom = 0f override fun onSurfaceCreated( unused: javax.microedition.khronos.opengles.GL10?, @@ -89,7 +150,9 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { // glClearColor 挪到 onDrawFrame:底色要跟随主题,而 onSurfaceCreated // 只在上下文创建时跑一次。 GLES20.glEnable(GLES20.GL_DEPTH_TEST) - GLES20.glEnable(GLES20.GL_BLEND) + // 混合方程一次设好,但**不**在这里开启:唯一需要它的是等高线热力图 + // (非有限单元的 alpha 为 0),曲面与所有线条的 alpha 恒为 1, + // 全程开着只是让整屏不透明像素白白走一遍混合。见 drawMesh。 GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA) program = compileProgram(VERTEX_SHADER, FRAGMENT_SHADER) axesProgram = compileProgram(AXES_VERTEX_SHADER, AXES_FRAGMENT_SHADER) @@ -107,10 +170,23 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { axesMvpUniform[0] = GLES20.glGetUniformLocation(axesProgram, "uMvp") axesPosAttr[0] = GLES20.glGetAttribLocation(axesProgram, "aPos") } - axisXBuf = createBuffer(GLES20.GL_ARRAY_BUFFER, AXIS_X) - axisYBuf = createBuffer(GLES20.GL_ARRAY_BUFFER, AXIS_Y) - axisZBuf = createBuffer(GLES20.GL_ARRAY_BUFFER, AXIS_Z) - frameBuf = createBuffer(GLES20.GL_ARRAY_BUFFER, CONTOUR_FRAME) + labelProgram = compileProgram(LABEL_VERTEX_SHADER, LABEL_FRAGMENT_SHADER) + if (labelProgram != 0) { + labelAnchorAttr[0] = GLES20.glGetAttribLocation(labelProgram, "aAnchor") + labelOffsetAttr[0] = GLES20.glGetAttribLocation(labelProgram, "aOffset") + labelUvAttr[0] = GLES20.glGetAttribLocation(labelProgram, "aUv") + labelMvpUniform[0] = GLES20.glGetUniformLocation(labelProgram, "uMvp") + labelViewportUniform[0] = GLES20.glGetUniformLocation(labelProgram, "uViewport") + labelColorUniform[0] = GLES20.glGetUniformLocation(labelProgram, "uColor") + labelTexUniform[0] = GLES20.glGetUniformLocation(labelProgram, "uTex") + } + // 上下文重建后纹理与缓冲名全部作废。这里**只清账、不删除**: + // 旧名字在新上下文里可能已经被重新分配出去,对它调 glDelete* + // 会删掉刚建好的别人的对象。同理 atlas 直接丢弃而不 release。 + atlas = null + atlasTextPx = -1f + forgetAxisBuffers() + axesDirty = true uploadMesh(mesh, range) } @@ -125,13 +201,18 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { } override fun onDrawFrame(unused: javax.microedition.khronos.opengles.GL10?) { - // 每帧读一次 volatile 并设一次状态,不分配。 + // 每帧各读一次 volatile:曲面与覆盖层必须看到同一个状态。 val p = paletteOverride ?: palette + val current = state val bg = p.background GLES20.glClearColor(bg[0], bg[1], bg[2], bg[3]) GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT) - drawMesh() - drawOverlays(p) + ensureAtlas() + ensureAxes(current.kind) + updateCamera(current) + drawMesh(current) + drawOverlays(current, p) + drawLabels(current, p) captureCallback?.let { cb -> captureCallback = null cb(readPixels()) @@ -142,6 +223,8 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { fun uploadMesh(newMesh: GlMesh?, newRange: PlotRange?) { mesh = newMesh range = newRange + // 坐标轴刻度是按数据范围算的,网格换了就得跟着换。 + axesDirty = true deleteBuffers() if (newMesh == null || newRange == null) return positionBuf = createBuffer(GLES20.GL_ARRAY_BUFFER, newMesh.positions) @@ -157,64 +240,121 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { captureCallback = callback } - private fun drawMesh() { + /** + * 字号变了就重建图集:字形度量变了,标签顶点也得跟着重算。 + * + * 门禁只看字号、不看 [atlas] 是否为 null:纹理创建失败时 atlas 就是 null, + * 拿它当条件会让每一帧都重新画一张位图再失败一次。失败就是失败, + * 这个字号下不再重试,标签静默缺席。 + */ + private fun ensureAtlas() { + val wanted = labelTextPx + if (wanted == atlasTextPx) return + atlas?.release() + atlas = GlyphAtlas.create(wanted) + atlasTextPx = wanted + axesDirty = true + } + + /** + * 重建坐标轴与标签的缓冲。只在网格/范围/模式/字号变化时跑—— + * [PlotGlAxes.build] 会分配,绝不能进逐帧路径。 + */ + private fun ensureAxes(kind: GlPlotKind) { + if (!axesDirty && kind == axesKind) return + axesDirty = false + axesKind = kind + deleteAxisBuffers() + val current = mesh ?: return val currentRange = range ?: return - if (program == 0 || positionBuf == 0 || current.indices.isEmpty()) return - - val kind = state.kind - val rotY = Mat4.rotationY(Math.toRadians(state.azimuthDeg.toDouble()).toFloat()) - val rotX = Mat4.rotationX(Math.toRadians(state.elevationDeg.toDouble()).toFloat()) - val zoom = state.zoom - val normalize = surfaceNormalize(currentRange, current.zMin, current.zMax) + val axes = PlotGlAxes.build(currentRange, current.zMin, current.zMax, kind) + axisXBuf = createBuffer(GLES20.GL_ARRAY_BUFFER, axes.axisX) + axisYBuf = createBuffer(GLES20.GL_ARRAY_BUFFER, axes.axisY) + axisZBuf = createBuffer(GLES20.GL_ARRAY_BUFFER, axes.axisZ) + frameBuf = createBuffer(GLES20.GL_ARRAY_BUFFER, axes.frame) + axisXCount = axes.axisX.size / 3 + axisYCount = axes.axisY.size / 3 + axisZCount = axes.axisZ.size / 3 + frameCount = axes.frame.size / 3 + + val glyphs = atlas + if (glyphs != null) { + val vertices = glyphs.metrics.buildVertices(axes.labels) + labelBuf = createBuffer(GLES20.GL_ARRAY_BUFFER, vertices) + labelVertexCount = vertices.size / GlyphMetrics.FLOATS_PER_VERTEX + } + } - val model: Mat4 - val proj: Mat4 - val view: Mat4 + /** 相机每帧只算一次,[drawMesh]、[drawOverlays] 与 [drawLabels] 共用结果。 */ + private fun updateCamera(s: GlViewState) { + val kind = s.kind + // 等高线是正交投影,与 zoom 无关:键里固定填 1,免得捏合时白重算。 + val zoomKey = if (kind == GlPlotKind.SURFACE) s.zoom.coerceAtLeast(MIN_PROJECTION_ZOOM) else 1f + if (kind != projKind || width != projWidth || height != projHeight || zoomKey != projZoom) { + val aspect = width.toFloat() / height + if (kind == GlPlotKind.SURFACE) { + // 缩放靠收窄视场:tan(fov'/2) = tan(fov/2)/zoom, + // 屏幕上的放大倍率因此与 zoom 严格成正比。 + val halfFov = atan(tan(FOV_RADIANS / 2f) / zoomKey) + projM.setPerspective(halfFov * 2f, aspect, NEAR_PLANE, FAR_PLANE) + } else { + projM.setOrtho(-aspect, aspect, -1f, 1f, -1f, 1f) + } + projKind = kind + projWidth = width + projHeight = height + projZoom = zoomKey + } if (kind == GlPlotKind.SURFACE) { - model = Mat4.identity() - .translate(state.panX, state.panY, 0f) - .multiply(rotY) - .multiply(rotX) - .scale(zoom) - .multiply(normalize) - view = Mat4.lookAt(0f, 0f, 4f / zoom, 0f, 0f, 0f, 0f, 1f, 0f) - proj = Mat4.perspective(Math.toRadians(45.0).toFloat(), width.toFloat() / height, 0.1f, 100f) + rotYM.setRotationY(Math.toRadians(s.azimuthDeg.toDouble()).toFloat()) + rotXM.setRotationX(Math.toRadians(s.elevationDeg.toDouble()).toFloat()) + // 相机不动。推近相机来缩放会让盒子最近的角穿过近平面被切掉。 + viewM.setLookAt(0f, 0f, EYE_DISTANCE, 0f, 0f, 0f, 0f, 1f, 0f) } else { - model = contourLineModel(state, currentRange) - view = Mat4.identity() - proj = Mat4.ortho( - -width.toFloat() / height, - width.toFloat() / height, - -1f, - 1f, - -1f, - 1f, - ) + viewM.setIdentity() + } + } + + private fun drawMesh(s: GlViewState) { + val current = mesh ?: return + val currentRange = range ?: return + if (program == 0 || positionBuf == 0 || current.indices.isEmpty()) return + + val shaded = s.kind == GlPlotKind.SURFACE + if (shaded) { + PlotGlModels.surfaceNormalizeInto(normalizeM, currentRange, current.zMin, current.zMax) + // 没有 scale(zoom):曲面的缩放由视场角承担,见 updateCamera。 + modelM.setIdentity() + .translate(s.panX, s.panY, 0f) + .multiply(rotYM) + .multiply(rotXM) + .multiply(normalizeM) + } else { + PlotGlModels.contourLineModelInto(modelM, s, currentRange) } - val mvp = Mat4.identity().set(proj).multiply(view).multiply(model) GLES20.glUseProgram(program) - GLES20.glUniformMatrix4fv(mvpUniform[0], 1, false, mvp.m, 0) + GLES20.glUniformMatrix4fv(mvpUniform[0], 1, false, mvp(modelM), 0) // 法线矩阵 = (R * S)^(-T) = R * S^(-1):每列(即 S^(-1) 的第 j 个对角元) // 修正非均匀归一化缩放(2/ex、2/ey、2/ez),否则光照会偏斜。 - val normalMat = if (kind == GlPlotKind.SURFACE) { + if (shaded) { val ex = (currentRange.xMax - currentRange.xMin).toFloat().coerceAtLeast(1e-6f) val ey = (currentRange.yMax - currentRange.yMin).toFloat().coerceAtLeast(1e-6f) val ez = (current.zMax - current.zMin).coerceAtLeast(1e-6f) - val rot = Mat4.identity().multiply(rotY).multiply(rotX) - Mat4.normalMatrix(rot, ex / 2f, ey / 2f, ez / 2f) + rotM.set(rotYM).multiply(rotXM) + Mat4.normalMatrixInto(normalMat, rotM, ex / 2f, ey / 2f, ez / 2f) + GLES20.glUniformMatrix3fv(normalUniform[0], 1, false, normalMat, 0) } else { - IDENTITY_MAT3 + GLES20.glUniformMatrix3fv(normalUniform[0], 1, false, IDENTITY_MAT3, 0) } - GLES20.glUniformMatrix3fv(normalUniform[0], 1, false, normalMat, 0) GLES20.glUniform3fv(lightUniform[0], 1, LIGHT_DIR, 0) - GLES20.glUniform1i(shadedUniform[0], if (kind == GlPlotKind.SURFACE) 1 else 0) + GLES20.glUniform1i(shadedUniform[0], if (shaded) 1 else 0) GLES20.glEnableVertexAttribArray(positionAttr[0]) GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, positionBuf) GLES20.glVertexAttribPointer(positionAttr[0], 3, GLES20.GL_FLOAT, false, 0, 0) - if (kind == GlPlotKind.SURFACE) { + if (shaded) { GLES20.glEnableVertexAttribArray(normalAttr[0]) GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, normalBuf) GLES20.glVertexAttribPointer(normalAttr[0], 3, GLES20.GL_FLOAT, false, 0, 0) @@ -223,7 +363,11 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, colorBuf) GLES20.glVertexAttribPointer(colorAttr[0], 4, GLES20.GL_FLOAT, false, 0, 0) GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, indexBuf) + // 唯一用得上混合的地方:等高线热力图靠 alpha=0 挖出非有限区域的洞。 + // 曲面顶点 alpha 恒为 1,开混合只会白白多一遍逐像素读改写。 + if (!shaded) GLES20.glEnable(GLES20.GL_BLEND) GLES20.glDrawElements(GLES20.GL_TRIANGLES, current.indices.size, GLES20.GL_UNSIGNED_SHORT, 0) + if (!shaded) GLES20.glDisable(GLES20.GL_BLEND) GLES20.glDisableVertexAttribArray(positionAttr[0]) GLES20.glDisableVertexAttribArray(normalAttr[0]) @@ -232,74 +376,104 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0) } - private fun drawOverlays(palette: GlPalette) { + private fun drawOverlays(s: GlViewState, palette: GlPalette) { if (axesProgram == 0) return - val kind = state.kind - val rotY = Mat4.rotationY(Math.toRadians(state.azimuthDeg.toDouble()).toFloat()) - val rotX = Mat4.rotationX(Math.toRadians(state.elevationDeg.toDouble()).toFloat()) - val zoom = state.zoom - val view = if (kind == GlPlotKind.SURFACE) { - Mat4.lookAt(0f, 0f, 4f / zoom, 0f, 0f, 0f, 0f, 1f, 0f) - } else { - Mat4.identity() - } - val proj = if (kind == GlPlotKind.SURFACE) { - Mat4.perspective(Math.toRadians(45.0).toFloat(), width.toFloat() / height, 0.1f, 100f) - } else { - Mat4.ortho( - -width.toFloat() / height, - width.toFloat() / height, - -1f, - 1f, - -1f, - 1f, - ) - } GLES20.glUseProgram(axesProgram) val mvpLoc = axesMvpUniform[0] val posLoc = axesPosAttr[0] GLES20.glEnableVertexAttribArray(posLoc) - if (kind == GlPlotKind.SURFACE) { - val model = Mat4.identity() - .translate(state.panX, state.panY, 0f) - .multiply(rotY) - .multiply(rotX) - .scale(zoom) - GLES20.glUniformMatrix4fv( - mvpLoc, 1, false, - Mat4.identity().set(proj).multiply(view).multiply(model).m, 0, - ) - drawLines(posLoc, colorUniform[0], axisXBuf, 2, palette.axisX) - drawLines(posLoc, colorUniform[0], axisYBuf, 2, palette.axisY) - drawLines(posLoc, colorUniform[0], axisZBuf, 2, palette.axisZ) + if (s.kind == GlPlotKind.SURFACE) { + overlayModelInto(modelM, s) + GLES20.glUniformMatrix4fv(mvpLoc, 1, false, mvp(modelM), 0) + // 包围盒与刻度短线先画:它们是数据边界的参照,被曲面遮住才合理。 + drawLines(posLoc, colorUniform[0], frameBuf, frameCount, palette.frame) + drawLines(posLoc, colorUniform[0], axisXBuf, axisXCount, palette.axisX) + drawLines(posLoc, colorUniform[0], axisYBuf, axisYCount, palette.axisY) + drawLines(posLoc, colorUniform[0], axisZBuf, axisZCount, palette.axisZ) } else { - val currentRange = range ?: return - // 热力图与等值线/边框都在 z=0:开启深度测试时后画的线会被 - // 同一深度的热力图遮挡,因此画线前临时关闭深度测试。 - GLES20.glDisable(GLES20.GL_DEPTH_TEST) - // 边框顶点已是归一化 [-1,1] 坐标,只应用 pan/zoom; - // 等值线顶点是数据坐标,需要先归一化,两者不能共用一个模型。 - GLES20.glUniformMatrix4fv( - mvpLoc, 1, false, - Mat4.identity().set(proj).multiply(view).multiply(contourFrameModel(state)).m, 0, - ) - drawLines(posLoc, colorUniform[0], frameBuf, 8, palette.frame) - if (contourBuf != 0 && contourSize > 0) { - GLES20.glUniformMatrix4fv( - mvpLoc, 1, false, - Mat4.identity().set(proj).multiply(view) - .multiply(contourLineModel(state, currentRange)).m, 0, - ) - drawLines(posLoc, colorUniform[0], contourBuf, contourSize, palette.contour) + val currentRange = range + if (currentRange != null) { + // 热力图与等值线/边框都在 z=0:开启深度测试时后画的线会被 + // 同一深度的热力图遮挡,因此画线前临时关闭深度测试。 + GLES20.glDisable(GLES20.GL_DEPTH_TEST) + // 边框与刻度顶点已是归一化 [-1,1] 坐标,只应用 pan/zoom; + // 等值线顶点是数据坐标,需要先归一化,两者不能共用一个模型。 + PlotGlModels.contourFrameModelInto(modelM, s) + GLES20.glUniformMatrix4fv(mvpLoc, 1, false, mvp(modelM), 0) + drawLines(posLoc, colorUniform[0], frameBuf, frameCount, palette.frame) + drawLines(posLoc, colorUniform[0], axisXBuf, axisXCount, palette.axisX) + drawLines(posLoc, colorUniform[0], axisYBuf, axisYCount, palette.axisY) + if (contourBuf != 0 && contourSize > 0) { + PlotGlModels.contourLineModelInto(modelM, s, currentRange) + GLES20.glUniformMatrix4fv(mvpLoc, 1, false, mvp(modelM), 0) + drawLines(posLoc, colorUniform[0], contourBuf, contourSize, palette.contour) + } + GLES20.glEnable(GLES20.GL_DEPTH_TEST) } - GLES20.glEnable(GLES20.GL_DEPTH_TEST) } GLES20.glDisableVertexAttribArray(posLoc) GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0) } + /** + * 覆盖层的模型矩阵:与曲面同样的 pan/旋转,但**不含**归一化那一步。 + * 坐标轴顶点本来就在归一化空间里([PlotGlAxes]),再归一化一次就错了。 + * + * 同样没有 scale(zoom)——必须与 [drawMesh] 的曲面模型保持一致, + * 否则包围盒会和它框住的曲面分家。 + */ + private fun overlayModelInto(dest: Mat4, s: GlViewState): Mat4 = + dest.setIdentity() + .translate(s.panX, s.panY, 0f) + .multiply(rotYM) + .multiply(rotXM) + + /** + * 刻度数值与轴名。最后画且关掉深度测试:标签是读数用的, + * 被曲面挡住就失去了意义。 + */ + private fun drawLabels(s: GlViewState, palette: GlPalette) { + val glyphs = atlas ?: return + if (labelProgram == 0 || labelBuf == 0 || labelVertexCount == 0) return + if (s.kind == GlPlotKind.SURFACE) { + overlayModelInto(modelM, s) + } else { + PlotGlModels.contourFrameModelInto(modelM, s) + } + + GLES20.glUseProgram(labelProgram) + GLES20.glUniformMatrix4fv(labelMvpUniform[0], 1, false, mvp(modelM), 0) + GLES20.glUniform2f(labelViewportUniform[0], width.toFloat(), height.toFloat()) + GLES20.glUniform4fv(labelColorUniform[0], 1, palette.label, 0) + GLES20.glActiveTexture(GLES20.GL_TEXTURE0) + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, glyphs.texture) + GLES20.glUniform1i(labelTexUniform[0], 0) + + GLES20.glDisable(GLES20.GL_DEPTH_TEST) + GLES20.glEnable(GLES20.GL_BLEND) + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, labelBuf) + val stride = GlyphMetrics.FLOATS_PER_VERTEX * 4 + GLES20.glEnableVertexAttribArray(labelAnchorAttr[0]) + GLES20.glVertexAttribPointer(labelAnchorAttr[0], 3, GLES20.GL_FLOAT, false, stride, 0) + GLES20.glEnableVertexAttribArray(labelOffsetAttr[0]) + GLES20.glVertexAttribPointer(labelOffsetAttr[0], 2, GLES20.GL_FLOAT, false, stride, 3 * 4) + GLES20.glEnableVertexAttribArray(labelUvAttr[0]) + GLES20.glVertexAttribPointer(labelUvAttr[0], 2, GLES20.GL_FLOAT, false, stride, 5 * 4) + GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, labelVertexCount) + GLES20.glDisableVertexAttribArray(labelAnchorAttr[0]) + GLES20.glDisableVertexAttribArray(labelOffsetAttr[0]) + GLES20.glDisableVertexAttribArray(labelUvAttr[0]) + GLES20.glDisable(GLES20.GL_BLEND) + GLES20.glEnable(GLES20.GL_DEPTH_TEST) + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0) + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0) + } + + /** proj * view * model,写进复用的 [mvpM] 并返回它的后备数组。 */ + private fun mvp(model: Mat4): FloatArray = mvpM.set(projM).multiply(viewM).multiply(model).m + private fun drawLines(posLoc: Int, colorLoc: Int, buffer: Int, count: Int, color: FloatArray) { if (buffer == 0 || count <= 0) return GLES20.glUniform4fv(colorLoc, 1, color, 0) @@ -308,31 +482,91 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { GLES20.glDrawArrays(GLES20.GL_LINES, 0, count) } + /** + * GL 的原点在左下、Bitmap 在左上,所以要上下对翻,同时把 RGBA 转成 ARGB。 + * 原地对调行即可,不必另开一个同样大的目标数组——1080p 下那是白白多出的 + * 8 MB 瞬时分配,而且发生在 GL 线程上。 + */ private fun readPixels(): Bitmap { val bytes = ByteBuffer.allocateDirect(width * height * 4).order(ByteOrder.nativeOrder()) GLES20.glReadPixels(0, 0, width, height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, bytes) - val src = IntArray(width * height) - bytes.asIntBuffer().get(src) - val dst = IntArray(width * height) - for (row in 0 until height) { + val pixels = IntArray(width * height) + bytes.asIntBuffer().get(pixels) + var top = 0 + var bottom = height - 1 + while (top < bottom) { + val topRow = top * width + val bottomRow = bottom * width for (col in 0 until width) { - val v = src[(height - 1 - row) * width + col] - val r = v and 0xFF - val g = (v shr 8) and 0xFF - val b = (v shr 16) and 0xFF - val a = (v shr 24) and 0xFF - dst[row * width + col] = (a shl 24) or (r shl 16) or (g shl 8) or b + val t = pixels[topRow + col] + pixels[topRow + col] = rgbaToArgb(pixels[bottomRow + col]) + pixels[bottomRow + col] = rgbaToArgb(t) } + top++ + bottom-- } - return Bitmap.createBitmap(dst, width, height, Bitmap.Config.ARGB_8888) + if (top == bottom) { + // 高度为奇数时的中间行:只换字节序,不换位置。 + val middle = top * width + for (col in 0 until width) { + pixels[middle + col] = rgbaToArgb(pixels[middle + col]) + } + } + return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) + } + + private fun rgbaToArgb(v: Int): Int { + val r = v and 0xFF + val g = (v shr 8) and 0xFF + val b = (v shr 16) and 0xFF + val a = (v shr 24) and 0xFF + return (a shl 24) or (r shl 16) or (g shl 8) or b + } + + /** 收集非 0 的缓冲 id 到复用的 [deleteIds],原实现每次都要 filter 出一个装箱 List。 */ + private fun collectBuffer(count: Int, buffer: Int): Int { + if (buffer == 0) return count + deleteIds[count] = buffer + return count + 1 + } + + /** 坐标轴与标签的缓冲;随范围/模式/字号重建,与网格缓冲各管各的。 */ + private fun deleteAxisBuffers() { + var n = 0 + n = collectBuffer(n, axisXBuf) + n = collectBuffer(n, axisYBuf) + n = collectBuffer(n, axisZBuf) + n = collectBuffer(n, frameBuf) + n = collectBuffer(n, labelBuf) + if (n > 0) { + GLES20.glDeleteBuffers(n, deleteIds, 0) + } + forgetAxisBuffers() + } + + /** 只把 id 归零,不碰 GL。上下文重建后旧名字已不属于我们,见 onSurfaceCreated。 */ + private fun forgetAxisBuffers() { + axisXBuf = 0 + axisYBuf = 0 + axisZBuf = 0 + frameBuf = 0 + labelBuf = 0 + axisXCount = 0 + axisYCount = 0 + axisZCount = 0 + frameCount = 0 + labelVertexCount = 0 } private fun deleteBuffers() { - val list = intArrayOf(positionBuf, normalBuf, colorBuf, indexBuf, contourBuf) - .filter { it != 0 } - .toIntArray() - if (list.isNotEmpty()) { - GLES20.glDeleteBuffers(list.size, list, 0) + var n = 0 + n = collectBuffer(n, positionBuf) + n = collectBuffer(n, normalBuf) + n = collectBuffer(n, colorBuf) + n = collectBuffer(n, indexBuf) + n = collectBuffer(n, contourBuf) + if (n > 0) { + GLES20.glDeleteBuffers(n, deleteIds, 0) } positionBuf = 0 normalBuf = 0 @@ -409,47 +643,53 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { */ private val LIGHT_DIR = normalize(floatArrayOf(0.4f, 0.7f, 0.8f)) private val IDENTITY_MAT3 = floatArrayOf(1f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f) + private val FOV_RADIANS = Math.toRadians(45.0).toFloat() - private fun normalize(v: FloatArray): FloatArray { - val len = sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) - return if (len == 0f) { - floatArrayOf(0f, 0f, 1f) - } else { - floatArrayOf(v[0] / len, v[1] / len, v[2] / len) - } - } + /** 只是防止除零,不是手势的缩放下限——那个在 GlGestureMath 里。 */ + private const val MIN_PROJECTION_ZOOM = 0.05f /** - * 曲面网格归一化:数据坐标 → [-1,1]^3,中心平移到原点。 - * 必须先平移再缩放(S*T),否则中心不会落在原点。 + * 归一化空间里坐标轴几何到原点的最大距离:包围盒的角是 √3≈1.732, + * 但刻度短线、数值标签和轴名都画在盒外,最远的是 z 轴名的锚点 + * (约 2.028)。取 2.05 留一点余量。 + * + * [PlotGlAxesTest] 会断言 [PlotGlAxes] 生成的每个顶点和标签锚点 + * 都在这个半径内——这个常量是相机参数的依据,不能靠手算维持。 */ - fun surfaceNormalize(range: PlotRange, zMin: Float, zMax: Float): Mat4 { - val ex = (range.xMax - range.xMin).toFloat().coerceAtLeast(1e-6f) - val ey = (range.yMax - range.yMin).toFloat().coerceAtLeast(1e-6f) - val ez = (zMax - zMin).coerceAtLeast(1e-6f) - return Mat4.identity() - .scale(2f / ex, 2f / ey, 2f / ez) - .translate(-range.centerX.toFloat(), -range.centerY.toFloat(), -(zMin + zMax) / 2f) - } + const val CONTENT_RADIUS = 2.05f /** - * 等高线边框模型:顶点已是归一化 [-1,1] 坐标,只应用 pan/zoom。 + * 相机到原点的距离。**不随 zoom 变**。 + * + * 原来是 `EYE_DISTANCE / zoom`,同时模型又乘了一遍 `scale(zoom)`: + * 缩放被应用了两次(捏合 2 倍实际放大 4 倍),而且相机推近到 zoom≈1.7 + * 时盒子最近的角就穿过近平面被切开了——曲面被切不容易看出来, + * 一条直棱被切非常显眼。现在缩放只由视场角承担,相机固定不动, + * 任何缩放级别都不可能切到几何。 + * + * 半径 R 的球完整落在**竖直**视场里的条件是 d ≥ R/sin(fov/2)。 + * 竖屏时宽高比小于 1、水平视场比竖直窄,角上的标签仍可能出屏—— + * 那要靠调小 PlotGlAxes 的间距或按宽高比退相机,不在本次改动内。 */ - fun contourFrameModel(state: GlViewState): Mat4 = - Mat4.identity() - .translate(state.panX, state.panY, 0f) - .scale(state.zoom) + private val EYE_DISTANCE = CONTENT_RADIUS / sin(FOV_RADIANS / 2f) /** - * 等高线等值线模型:顶点是数据坐标,需先归一化(与热力图 mesh 完全一致)。 + * 近远平面贴着内容取,而不是 0.1..100,深度精度好得多。 + * + * 留一成余量:正好取 EYE±R 的话,半径 R 上的点就压在平面上, + * 舍入到哪一侧全看运气。3.10..7.61 相比 0.1..100 依然是巨大的收窄。 */ - fun contourLineModel(state: GlViewState, range: PlotRange): Mat4 { - val ex = (range.xMax - range.xMin).toFloat().coerceAtLeast(1e-6f) - val ey = (range.yMax - range.yMin).toFloat().coerceAtLeast(1e-6f) - val normalizeXY = Mat4.identity() - .scale(2f / ex, 2f / ey, 1f) - .translate(-range.centerX.toFloat(), -range.centerY.toFloat(), 0f) - return contourFrameModel(state).multiply(normalizeXY) + private const val DEPTH_MARGIN = 1.1f + private val NEAR_PLANE = EYE_DISTANCE - CONTENT_RADIUS * DEPTH_MARGIN + private val FAR_PLANE = EYE_DISTANCE + CONTENT_RADIUS * DEPTH_MARGIN + + private fun normalize(v: FloatArray): FloatArray { + val len = sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) + return if (len == 0f) { + floatArrayOf(0f, 0f, 1f) + } else { + floatArrayOf(v[0] / len, v[1] / len, v[2] / len) + } } private const val VERTEX_SHADER = """ @@ -462,7 +702,8 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { varying vec4 vColor; void main() { gl_Position = uMvp * vec4(aPos, 1.0); - vNormal = normalize(uNormalMat * aNormal); + // 这里不归一化:插值本来就会破坏单位长度,片元里还得再来一次。 + vNormal = uNormalMat * aNormal; vColor = aColor; } """ @@ -474,9 +715,15 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { uniform vec3 uLightDir; uniform int uShaded; void main() { - float diff = max(dot(normalize(vNormal), normalize(uLightDir)), 0.0); - float light = uShaded == 1 ? 0.35 + 0.65 * diff : 1.0; - gl_FragColor = vec4(vColor.rgb * light, vColor.a); + // uShaded 是 uniform,写成真正的分支,等高线那条路径 + // (整屏热力图 + 4x MSAA,最吃填充率的一处)就完全不必算光照。 + if (uShaded == 1) { + // uLightDir 在 CPU 侧已归一化,见 LIGHT_DIR。 + float diff = max(dot(normalize(vNormal), uLightDir), 0.0); + gl_FragColor = vec4(vColor.rgb * (0.35 + 0.65 * diff), vColor.a); + } else { + gl_FragColor = vColor; + } } """ @@ -496,14 +743,44 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { } """ - private val AXIS_X = floatArrayOf(-1.2f, 0f, 0f, 1.2f, 0f, 0f) - private val AXIS_Y = floatArrayOf(0f, -1.2f, 0f, 0f, 1.2f, 0f) - private val AXIS_Z = floatArrayOf(0f, 0f, -1.2f, 0f, 0f, 1.2f) - private val CONTOUR_FRAME = floatArrayOf( - -1f, -1f, 0f, 1f, -1f, 0f, - 1f, -1f, 0f, 1f, 1f, 0f, - 1f, 1f, 0f, -1f, 1f, 0f, - -1f, 1f, 0f, -1f, -1f, 0f, - ) + /** + * 标签着色器。锚点先按 MVP 投影,再在裁剪空间里按**像素**偏移平移: + * 偏移乘上 clip.w 抵消随后的透视除法,于是标签始终正对屏幕、 + * 字号恒定,不会随着盒子转到远处而缩小成一团。 + */ + private const val LABEL_VERTEX_SHADER = """ + attribute vec3 aAnchor; + attribute vec2 aOffset; + attribute vec2 aUv; + uniform mat4 uMvp; + uniform vec2 uViewport; + varying vec2 vUv; + void main() { + vec4 clip = uMvp * vec4(aAnchor, 1.0); + if (clip.w <= 0.0) { + // 锚点在相机背后:丢到裁剪体外。否则透视除法会把它 + // 翻到屏幕正面,画出一串鬼影数字。 + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + } else { + clip.xy += aOffset / uViewport * 2.0 * clip.w; + gl_Position = clip; + } + vUv = aUv; + } + """ + + private const val LABEL_FRAGMENT_SHADER = """ + precision mediump float; + varying vec2 vUv; + uniform sampler2D uTex; + uniform vec4 uColor; + void main() { + // 图集只有 alpha 有意义,颜色跟主题走。 + gl_FragColor = vec4(uColor.rgb, uColor.a * texture2D(uTex, vUv).a); + } + """ + + /** 密度未知时的标签字号;实际值由控制器按屏幕密度推下来。 */ + const val DEFAULT_LABEL_PX = 28f } } diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlView.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlView.kt index 604d12e..10f89b1 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlView.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlView.kt @@ -8,6 +8,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import com.paruh.maxmath.R import com.paruh.maxmath.ui.plot.PlotRange @@ -67,6 +69,15 @@ class PlotGlController { requestRender() } + /** + * 坐标轴数值标签的字号(像素)。渲染器不认识屏幕密度,只能由界面层 + * 把 sp 换算好推下来;变了会触发字形图集与标签顶点重建。 + */ + fun setLabelTextSize(px: Float) { + renderer.labelTextPx = px + requestRender() + } + /** * 异步截取当前 GL 帧(回调在 GL 线程)。 * @@ -98,11 +109,17 @@ fun PlotGlSurface( palette: GlPalette = GlPalette.Light, ) { val context = LocalContext.current + val density = LocalDensity.current val view = remember { GLSurfaceView(context).apply { controller.attach(this) } } + // 与 2D 画布的刻度标签同一个字号(Plot2DPainter 用 10.sp)。 + val labelPx = with(density) { 10.sp.toPx() } LaunchedEffect(view) { view.onResume() controller.refresh() } + LaunchedEffect(labelPx) { + controller.setLabelTextSize(labelPx) + } // 首次组合时也会触发:这个 Surface 只在 glMesh 就绪后才进入组合树, // 所以配色必须在这里推一次,不能只靠后续的主题切换。 LaunchedEffect(palette) { diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt index fd53f14..0b0fee8 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -76,6 +77,7 @@ import com.paruh.maxmath.ui.plot.PlotViewModel import com.paruh.maxmath.ui.plot.gl.GlPlotKind import com.paruh.maxmath.ui.plot.gl.GlViewState import com.paruh.maxmath.ui.plot.gl.PlotGlController +import com.paruh.maxmath.ui.plot.gl.PlotGlMesh import com.paruh.maxmath.ui.plot.gl.PlotGlSurface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -117,6 +119,8 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) { var live2D by remember { mutableStateOf(false) } var viewport by remember { mutableStateOf(null) } var glState by remember { mutableStateOf(GlViewState()) } + // 当前曲面网格密度。记着它,旋转(不改缩放)才不会白白重建一次网格。 + var glGrid by remember { mutableIntStateOf(PlotGlMesh.DEFAULT_GRID) } val glController = remember { PlotGlController() } val currentViewport by rememberUpdatedState(viewport) @@ -176,6 +180,7 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) { offset = Offset.Zero live2D = false touchCoord = null + glGrid = PlotGlMesh.DEFAULT_GRID glState = when (mode) { PlotKind.PLOT_3D -> GlViewState( kind = GlPlotKind.SURFACE, @@ -332,17 +337,40 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) { do { val event = awaitPointerEvent() } while (event.changes.any { it.pressed }) - if (mode == PlotKind.PLOT_2D) { - val base = currentViewport ?: return@awaitEachGesture - viewport = PlotGestureMath.transform( - base, - currentScale, - currentOffset, - imgW, - imgH, - ) - scale = 1f - offset = Offset.Zero + // 松手时把手势结果并进数据范围并重新采样。三种模式的 + // 共同点是:拖动过程中只做变换(便宜),停下来才按新的 + // 可见区域重新算一遍(准确)。 + val base = currentViewport ?: return@awaitEachGesture + when (mode) { + PlotKind.PLOT_2D -> { + viewport = PlotGestureMath.transform( + base, + currentScale, + currentOffset, + imgW, + imgH, + ) + scale = 1f + offset = Offset.Zero + } + PlotKind.CONTOUR -> { + // 平移/缩放改的是可见的数据区间,按它重新采样, + // 拖出原范围也能看到真正的函数值而不是空白。 + val next = GlGestureMath.contourRange(base, glState) + glState = GlGestureMath.resetView(glState) + glController.setState(glState) + glGrid = PlotGlMesh.DEFAULT_GRID + vm.resample(next, glGrid) + } + PlotKind.PLOT_3D -> { + // 曲面拖动是旋转,范围不变;只有缩放需要更密的 + // 网格。密度没变就什么都不做——旋转不该触发重建。 + val grid = PlotGlMesh.gridFor(glState.zoom) + if (grid != glGrid) { + glGrid = grid + vm.resample(base, grid) + } + } } } } diff --git a/app/src/main/java/com/paruh/maxmath/ui/theme/PlotColors.kt b/app/src/main/java/com/paruh/maxmath/ui/theme/PlotColors.kt index 6a7d3b2..d57e458 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/theme/PlotColors.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/theme/PlotColors.kt @@ -102,6 +102,8 @@ class GlPalette( val axisZ: FloatArray, val frame: FloatArray, val contour: FloatArray, + /** 坐标轴数值标签。与 [PlotPalette.tickLabel] 取同一个颜色。 */ + val label: FloatArray, ) { companion object { val Light = GlPalette( @@ -111,6 +113,8 @@ class GlPalette( axisZ = floatArrayOf(0.25f, 0.35f, 0.85f, 1f), frame = floatArrayOf(0.35f, 0.35f, 0.35f, 1f), contour = floatArrayOf(0.12f, 0.12f, 0.12f, 1f), + // #5A5A5A + label = floatArrayOf(0.353f, 0.353f, 0.353f, 1f), ) val Dark = GlPalette( @@ -121,6 +125,8 @@ class GlPalette( axisZ = floatArrayOf(0.50f, 0.62f, 1.0f, 1f), frame = floatArrayOf(0.55f, 0.58f, 0.60f, 1f), contour = floatArrayOf(0.88f, 0.90f, 0.92f, 1f), + // #B7BDC3 + label = floatArrayOf(0.718f, 0.741f, 0.765f, 1f), ) } } diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/GlGestureMathTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/GlGestureMathTest.kt index 27934dc..fe2cea4 100644 --- a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/GlGestureMathTest.kt +++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/GlGestureMathTest.kt @@ -58,4 +58,60 @@ class GlGestureMathTest { assertEquals(8f, GlGestureMath.applyContour(state, Offset.Zero, 100f, 100f, 100f).zoom, 1e-4f) assertEquals(0.5f, GlGestureMath.applyContour(state, Offset.Zero, 0.01f, 100f, 100f).zoom, 1e-4f) } + + @Test + fun `contour range is identity when the view was never moved`() { + val base = PlotRange(-5.0, 5.0, -2.0, 6.0) + val next = GlGestureMath.contourRange(base, GlViewState(kind = GlPlotKind.CONTOUR)) + assertEquals(base.xMin, next.xMin, 1e-9) + assertEquals(base.xMax, next.xMax, 1e-9) + assertEquals(base.yMin, next.yMin, 1e-9) + assertEquals(base.yMax, next.yMax, 1e-9) + } + + @Test + fun `contour zoom narrows the range around the centre`() { + val base = PlotRange(-4.0, 4.0, -4.0, 4.0) + val next = GlGestureMath.contourRange(base, GlViewState(kind = GlPlotKind.CONTOUR, zoom = 2f)) + assertEquals("放大两倍应只剩一半宽度", 4.0, next.xMax - next.xMin, 1e-9) + assertEquals(0.0, next.centerX, 1e-9) + assertEquals(0.0, next.centerY, 1e-9) + } + + @Test + fun `contour pan shifts the range without resizing it`() { + val base = PlotRange(-4.0, 4.0, -4.0, 4.0) + // 向右拖 = panX 变正 = 看到的是更小的 x + val next = GlGestureMath.contourRange( + base, + GlViewState(kind = GlPlotKind.CONTOUR, panX = 0.5f), + ) + assertEquals(8.0, next.xMax - next.xMin, 1e-9) + assertEquals(-2.0, next.centerX, 1e-9) + } + + /** + * 松手那一刻画面不能跳:重采样前后,同一个数据点必须落在同一个位置。 + * 手势期间的位置是 `n * zoom + pan`;重采样并把视图归位后是新范围下的 n。 + */ + @Test + fun `rebaking the range and resetting the view keeps points put`() { + val base = PlotRange(-5.0, 5.0, -2.0, 6.0) + val moved = GlViewState(kind = GlPlotKind.CONTOUR, panX = -0.8f, panY = 0.35f, zoom = 3f) + val next = GlGestureMath.contourRange(base, moved) + val reset = GlGestureMath.resetView(moved) + assertEquals(0f, reset.panX, 0f) + assertEquals(0f, reset.panY, 0f) + assertEquals(1f, reset.zoom, 0f) + + fun norm(v: Double, min: Double, max: Double) = 2.0 * (v - (min + max) / 2.0) / (max - min) + for (t in 0..10) { + val x = base.xMin + base.width * t / 10.0 + val y = base.yMin + base.height * t / 10.0 + val beforeX = norm(x, base.xMin, base.xMax) * moved.zoom + moved.panX + val beforeY = norm(y, base.yMin, base.yMax) * moved.zoom + moved.panY + assertEquals(beforeX, norm(x, next.xMin, next.xMax), 1e-9) + assertEquals(beforeY, norm(y, next.yMin, next.yMax), 1e-9) + } + } } diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/PlotTicksTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/PlotTicksTest.kt new file mode 100644 index 0000000..1de149f --- /dev/null +++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/PlotTicksTest.kt @@ -0,0 +1,167 @@ +package com.paruh.maxmath.ui.plot + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.abs + +class PlotTicksTest { + + private fun ticks(min: Double, max: Double, target: Int = PlotTicks.TARGET_2D): DoubleArray { + val capacity = PlotTicks.capacity(min, max, target) + val dest = DoubleArray(capacity) + val count = PlotTicks.into(dest, min, max, target) + return dest.copyOf(count) + } + + @Test + fun `step is a 1-2-5 multiple of a power of ten`() { + val spans = doubleArrayOf(1.0, 3.0, 7.0, 10.0, 0.037, 1234.0, 9.9e7, 4.2e-6) + for (span in spans) { + val step = PlotTicks.step(span, PlotTicks.TARGET_2D) + assertTrue("$span 的步长应为正", step > 0.0) + // step / 10^floor(log10(step)) 必须是 1、2 或 5 + val exp = Math.floor(Math.log10(step)) + val mantissa = step / Math.pow(10.0, exp) + val nearest = doubleArrayOf(1.0, 2.0, 5.0).minByOrNull { abs(it - mantissa) }!! + assertEquals("span=$span step=$step 的尾数", nearest, mantissa, 1e-9) + } + } + + @Test + fun `degenerate spans produce nothing instead of looping`() { + assertEquals(0.0, PlotTicks.step(0.0, 8), 0.0) + assertEquals(0.0, PlotTicks.step(-1.0, 8), 0.0) + assertEquals(0, PlotTicks.capacity(1.0, 1.0, 8)) + assertEquals(0, PlotTicks.into(DoubleArray(4), 1.0, 1.0, 8)) + assertEquals(0.0, PlotTicks.step(1.0, 0), 0.0) + } + + @Test + fun `ticks are inside the range ascending and on step multiples`() { + val cases = listOf( + -5.0 to 5.0, + 0.0 to 1.0, + -0.003 to 0.004, + 100.0 to 100.5, + -1e6 to 1e6, + ) + for ((min, max) in cases) { + val values = ticks(min, max) + assertTrue("$min..$max 应至少有一个刻度", values.isNotEmpty()) + val step = PlotTicks.step(max - min, PlotTicks.TARGET_2D) + for (i in values.indices) { + val v = values[i] + val slack = step * 1e-9 + assertTrue("$v 落在 $min..$max 外", v >= min - slack && v <= max + slack) + if (i > 0) assertTrue("刻度未递增", v > values[i - 1]) + val multiples = v / step + assertEquals("$v 不是 $step 的整数倍", Math.round(multiples).toDouble(), multiples, 1e-6) + } + } + } + + @Test + fun `capacity is an upper bound on the tick count`() { + var min = -7.3 + while (min < 7.0) { + var span = 0.017 + while (span < 500.0) { + val capacity = PlotTicks.capacity(min, min + span, PlotTicks.TARGET_2D) + // 传一个足够大的数组,确认真实数量不超过 capacity 的估计 + val generous = DoubleArray(capacity + 64) + val count = PlotTicks.into(generous, min, min + span, PlotTicks.TARGET_2D) + assertTrue("min=$min span=$span 实际 $count 超过容量 $capacity", count <= capacity) + span *= 2.7 + } + min += 1.9 + } + } + + @Test + fun `gl target yields fewer ticks than the 2d target`() { + assertTrue( + ticks(-5.0, 5.0, PlotTicks.TARGET_GL).size <= ticks(-5.0, 5.0, PlotTicks.TARGET_2D).size, + ) + } + + @Test + fun `labels drop trailing zeros`() { + assertEquals("0", PlotTicks.format(0.0)) + assertEquals("2", PlotTicks.format(2.0)) + assertEquals("-2", PlotTicks.format(-2.0)) + assertEquals("0.5", PlotTicks.format(0.5)) + assertEquals("2.5", PlotTicks.format(2.5)) + assertEquals("1000", PlotTicks.format(1000.0)) + assertEquals("0.001", PlotTicks.format(0.001)) + } + + @Test + fun `labels absorb the noise in ceil times step`() { + // 0.1 + 0.2 那一类:刻度是 ceil(min/step)*step 算出来的,末位常有噪声。 + assertEquals("0.3", PlotTicks.format(0.30000000000000004)) + assertEquals("-0.7", PlotTicks.format(-0.6999999999999998)) + } + + @Test + fun `labels switch to exponent form outside the readable band`() { + assertEquals("1e5", PlotTicks.format(100000.0)) + assertEquals("-2.5e6", PlotTicks.format(-2500000.0)) + assertEquals("1e-5", PlotTicks.format(0.00001)) + assertEquals("3e-7", PlotTicks.format(0.0000003)) + } + + @Test + fun `labels always use a decimal point regardless of default locale`() { + val original = java.util.Locale.getDefault() + try { + // 德语区默认用逗号做小数点,数字标签不该跟着变。 + java.util.Locale.setDefault(java.util.Locale.GERMANY) + assertEquals("0.5", PlotTicks.format(0.5)) + assertEquals("1.5e6", PlotTicks.format(1500000.0)) + } finally { + java.util.Locale.setDefault(original) + } + } + + @Test + fun `non finite values format to empty rather than throwing`() { + assertEquals("", PlotTicks.format(Double.NaN)) + assertEquals("", PlotTicks.format(Double.POSITIVE_INFINITY)) + } + + /** + * 零点/极值坐标不是刻度,落在哪儿都有可能。用刻度的定点格式会把有效数字 + * 削掉——下面第一对断言就是这个区别本身。 + */ + @Test + fun `formatValue keeps four significant digits where format would not`() { + assertEquals("0.000123", PlotTicks.formatValue(0.000123)) + assertEquals("0.0001", PlotTicks.format(0.000123)) + + assertEquals("1235", PlotTicks.formatValue(1234.5678)) + assertEquals("12.35", PlotTicks.formatValue(12.345678)) + assertEquals("1.235", PlotTicks.formatValue(1.2345678)) + assertEquals("0.1235", PlotTicks.formatValue(0.12345678)) + assertEquals("-0.1235", PlotTicks.formatValue(-0.12345678)) + // 1e4..1e5 之间小数位取 0,直接给整数——比截成四位有效数字更有用。 + assertEquals("12346", PlotTicks.formatValue(12345.6)) + } + + @Test + fun `formatValue shares the exponent form locale and edge cases`() { + assertEquals("0", PlotTicks.formatValue(0.0)) + assertEquals("", PlotTicks.formatValue(Double.NaN)) + assertEquals("", PlotTicks.formatValue(Double.NEGATIVE_INFINITY)) + assertEquals("1.235e6", PlotTicks.formatValue(1234567.0)) + assertEquals("1.5e-7", PlotTicks.formatValue(1.5e-7)) + + val original = java.util.Locale.getDefault() + try { + java.util.Locale.setDefault(java.util.Locale.GERMANY) + assertEquals("1.235", PlotTicks.formatValue(1.2345678)) + } finally { + java.util.Locale.setDefault(original) + } + } +} diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/PlotViewModelTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/PlotViewModelTest.kt index b1e8ea2..eaceffb 100644 --- a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/PlotViewModelTest.kt +++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/PlotViewModelTest.kt @@ -4,6 +4,7 @@ import com.paruh.maxmath.engine.CalcRequest import com.paruh.maxmath.engine.CalcResponse import com.paruh.maxmath.engine.PlotKind import com.paruh.maxmath.engine.PlotTask +import com.paruh.maxmath.ui.plot.gl.PlotGlMesh import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -14,6 +15,7 @@ import org.json.JSONArray import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -116,6 +118,113 @@ class PlotViewModelTest { assertTrue(vm.state.value.glMesh!!.contourLines.isNotEmpty()) } + @Test + fun `resample rebuilds over the new range without touching the engine`() = runTest(dispatcher) { + val fake = FakeEngine() + val vm = PlotViewModel(fake, ApplicationProvider.getApplicationContext(), dispatcher) + vm.regenerate( + plot2d.copy(kind = PlotKind.CONTOUR, expression = "x^2+y^2", functions = emptyList()), + ) + dispatcher.scheduler.advanceUntilIdle() + val first = vm.state.value.glMesh!! + // -2..2 上 x^2+y^2 最大 8 + assertEquals(8f, first.zMax, 0.05f) + + vm.resample(PlotRange(0.0, 1.0, 0.0, 1.0), PlotGlMesh.DEFAULT_GRID) + dispatcher.scheduler.advanceUntilIdle() + + val state = vm.state.value + assertEquals("重采样是纯本地计算,不该惊动引擎", 0, fake.calls) + assertEquals(PlotRange(0.0, 1.0, 0.0, 1.0), state.glRange) + assertEquals("视口也要跟着走,刻度是按它算的", PlotRange(0.0, 1.0, 0.0, 1.0), state.range) + assertTrue("应当是一张新网格", state.glMesh !== first) + // 0..1 上 x^2+y^2 最大 2:确实按新范围重新求值了,而不是把旧网格拉伸 + assertEquals(2f, state.glMesh!!.zMax, 0.05f) + assertTrue(state.glMesh!!.contourLines.isNotEmpty()) + } + + @Test + fun `resample at a denser grid keeps the range and adds detail`() = runTest(dispatcher) { + val fake = FakeEngine() + val vm = PlotViewModel(fake, ApplicationProvider.getApplicationContext(), dispatcher) + vm.regenerate( + plot2d.copy(kind = PlotKind.PLOT_3D, expression = "sin(x)*cos(y)", functions = emptyList()), + ) + dispatcher.scheduler.advanceUntilIdle() + val coarse = vm.state.value.glMesh!! + + val range = vm.state.value.glRange!! + vm.resample(range, PlotGlMesh.MAX_GRID) + dispatcher.scheduler.advanceUntilIdle() + + val fine = vm.state.value.glMesh!! + assertEquals("加密不改范围", range, vm.state.value.glRange) + assertTrue("顶点应当变多", fine.positions.size > coarse.positions.size) + } + + /** + * 重采样与完整重绘各有各的 Job。合用一个的话,手势会取消掉正在跑的重绘, + * 而重采样这条路径从不置 loading——进度条就再也收不回去了。 + */ + @Test + fun `resample is dropped while a full redraw is in flight`() = runTest(dispatcher) { + val fake = FakeEngine() + val vm = PlotViewModel(fake, ApplicationProvider.getApplicationContext(), dispatcher) + val contour = plot2d.copy( + kind = PlotKind.CONTOUR, + expression = "x^2+y^2", + functions = emptyList(), + ) + vm.regenerate(contour) + dispatcher.scheduler.advanceUntilIdle() + + // 用户改了范围又点「绘图」,协程还没跑;此刻拖动屏幕上那张旧图。 + vm.regenerate(contour.copy(xMin = "-3", xMax = "3", yMin = "-3", yMax = "3")) + assertTrue("重绘尚未完成,应当在 loading", vm.state.value.loading) + vm.resample(PlotRange(0.0, 1.0, 0.0, 1.0), PlotGlMesh.DEFAULT_GRID) + dispatcher.scheduler.advanceUntilIdle() + + assertFalse("进度条必须收掉", vm.state.value.loading) + assertEquals( + "用户敲进输入框的范围比手势反推的更权威", + PlotRange(-3.0, 3.0, -3.0, 3.0), + vm.state.value.glRange, + ) + // -3..3 上 x^2+y^2 最大 18;若被重采样覆盖成 0..1 只会是 2。 + assertEquals(18f, vm.state.value.glMesh!!.zMax, 0.2f) + } + + @Test + fun `a new full redraw cancels a pending resample`() = runTest(dispatcher) { + val fake = FakeEngine() + val vm = PlotViewModel(fake, ApplicationProvider.getApplicationContext(), dispatcher) + val contour = plot2d.copy( + kind = PlotKind.CONTOUR, + expression = "x^2+y^2", + functions = emptyList(), + ) + vm.regenerate(contour) + dispatcher.scheduler.advanceUntilIdle() + + vm.resample(PlotRange(0.0, 1.0, 0.0, 1.0), PlotGlMesh.DEFAULT_GRID) + vm.regenerate(contour.copy(xMin = "-3", xMax = "3", yMin = "-3", yMax = "3")) + dispatcher.scheduler.advanceUntilIdle() + + assertFalse(vm.state.value.loading) + assertEquals(PlotRange(-3.0, 3.0, -3.0, 3.0), vm.state.value.glRange) + } + + @Test + fun `resample before any gl plot does nothing`() = runTest(dispatcher) { + val fake = FakeEngine() + val vm = PlotViewModel(fake, ApplicationProvider.getApplicationContext(), dispatcher) + vm.resample(PlotRange(0.0, 1.0, 0.0, 1.0), PlotGlMesh.DEFAULT_GRID) + dispatcher.scheduler.advanceUntilIdle() + + assertNull(vm.state.value.glMesh) + assertNull(vm.state.value.glRange) + } + @Test fun `3d regenerate with reversed range fails`() = runTest(dispatcher) { val fake = FakeEngine() diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/GlyphMetricsTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/GlyphMetricsTest.kt new file mode 100644 index 0000000..9850af9 --- /dev/null +++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/GlyphMetricsTest.kt @@ -0,0 +1,111 @@ +package com.paruh.maxmath.ui.plot.gl + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 顶点排版是喂给 GPU 的裸数组,排错了不会报错,只会画歪。 + * 用合成的度量数组构造 [GlyphMetrics],把布局约定钉死在这里。 + */ +class GlyphMetricsTest { + + private val n = GlyphMetrics.CHARS.length + + /** 每个字形宽 10px、字符盒 14px(含两侧 2px 边距),高 20px。 */ + private fun metrics() = GlyphMetrics( + advance = FloatArray(n) { 10f }, + cellWidth = FloatArray(n) { 14f }, + u0 = FloatArray(n) { it * 0.05f }, + u1 = FloatArray(n) { it * 0.05f + 0.04f }, + cellHeight = 20f, + v1 = 0.5f, + ) + + private fun floatsPerGlyph() = + GlyphMetrics.VERTICES_PER_GLYPH * GlyphMetrics.FLOATS_PER_VERTEX + + @Test + fun `each drawable character contributes two triangles`() { + val out = metrics().buildVertices(listOf(AxisLabel("123", 0f, 0f, 0f))) + assertEquals(3 * floatsPerGlyph(), out.size) + } + + @Test + fun `characters outside the atlas are skipped rather than drawn wrong`() { + // 'Q' 不在 CHARS 里。跳过它,而不是拿 indexOf 的 -1 去索引数组。 + val out = metrics().buildVertices(listOf(AxisLabel("1Q2", 0f, 0f, 0f))) + assertEquals(2 * floatsPerGlyph(), out.size) + } + + @Test + fun `a label with nothing drawable produces no vertices`() { + assertEquals(0, metrics().buildVertices(listOf(AxisLabel("", 0f, 0f, 0f))).size) + assertEquals(0, metrics().buildVertices(listOf(AxisLabel("QQ", 1f, 2f, 3f))).size) + assertEquals(0, metrics().buildVertices(emptyList()).size) + } + + @Test + fun `every vertex of a label carries the same anchor`() { + // 锚点是标签整体的位置,逐顶点重复;偏移才是像素级的排版。 + val out = metrics().buildVertices(listOf(AxisLabel("42", 0.25f, -1.5f, 0.75f))) + val stride = GlyphMetrics.FLOATS_PER_VERTEX + for (v in 0 until out.size / stride) { + assertEquals(0.25f, out[v * stride], 0f) + assertEquals(-1.5f, out[v * stride + 1], 0f) + assertEquals(0.75f, out[v * stride + 2], 0f) + } + } + + @Test + fun `text is centred on the anchor both ways`() { + val stride = GlyphMetrics.FLOATS_PER_VERTEX + val out = metrics().buildVertices(listOf(AxisLabel("12", 0f, 0f, 0f))) + var minX = Float.MAX_VALUE + var maxX = -Float.MAX_VALUE + var minY = Float.MAX_VALUE + var maxY = -Float.MAX_VALUE + for (v in 0 until out.size / stride) { + val ox = out[v * stride + 3] + val oy = out[v * stride + 4] + if (ox < minX) minX = ox + if (ox > maxX) maxX = ox + if (oy < minY) minY = oy + if (oy > maxY) maxY = oy + } + // 两个字形共 20px 宽,各带 2px 边距 ⇒ 覆盖 -12..12,中心在 0。 + assertEquals(0f, minX + maxX, 1e-4f) + assertEquals(-12f, minX, 1e-4f) + assertEquals(12f, maxX, 1e-4f) + // 纵向按字形盒居中:-10..10。 + assertEquals(-10f, minY, 1e-4f) + assertEquals(10f, maxY, 1e-4f) + } + + @Test + fun `glyphs advance left to right in order`() { + val stride = GlyphMetrics.FLOATS_PER_VERTEX + val glyph = floatsPerGlyph() + val out = metrics().buildVertices(listOf(AxisLabel("123", 0f, 0f, 0f))) + // 每个字形的第一个顶点是它的左上角,应当每次前进一个 advance。 + val first = out[3] + val second = out[glyph + 3] + val third = out[2 * glyph + 3] + assertEquals(10f, second - first, 1e-4f) + assertEquals(10f, third - second, 1e-4f) + assertTrue(first < second && second < third) + // uv 取自各自的字符:'1' 是 CHARS 里的下标 1,'2' 是 2。 + assertEquals(1 * 0.05f, out[5], 1e-6f) + assertEquals(2 * 0.05f, out[glyph + 5], 1e-6f) + } + + @Test + fun `multiple labels are packed back to back`() { + val out = metrics().buildVertices( + listOf(AxisLabel("1", 0f, 0f, 0f), AxisLabel("2", 1f, 1f, 1f)), + ) + assertEquals(2 * floatsPerGlyph(), out.size) + // 第二个标签的顶点带的是第二个锚点。 + assertEquals(1f, out[floatsPerGlyph()], 0f) + } +} diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/Mat4Test.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/Mat4Test.kt index 161e2c1..89bd1d8 100644 --- a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/Mat4Test.kt +++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/Mat4Test.kt @@ -1,5 +1,6 @@ package com.paruh.maxmath.ui.plot.gl +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -46,6 +47,100 @@ class Mat4Test { assertEquals(0f, p[2], 1e-5f) } + /** + * [Mat4.translate] 与 [Mat4.scale] 是原地改列实现的,不再构造矩阵去乘。 + * 上面几个用例全都作用在单位矩阵上——列号写错在那里看不出来, + * 所以这里拿一个非单位的基底,对着「构造矩阵再通用相乘」逐位比。 + */ + @Test + fun `in place translate and scale match building the matrix and multiplying`() { + fun base() = Mat4.identity().rotateY(0.7f).rotateX(-0.4f) + + val inPlace = base().translate(1.5f, -2f, 0.25f).scale(0.5f, 2f, 3f) + val viaMultiply = base() + .multiply(Mat4.translation(1.5f, -2f, 0.25f)) + .multiply(Mat4.scaling(0.5f, 2f, 3f)) + + for (i in 0 until 16) { + assertEquals("m[$i]", viaMultiply.m[i], inPlace.m[i], 0f) + } + } + + @Test + fun `translate and scale apply before a rotated base`() { + // 先平移再绕 Y 转 90°:(0,0,0) → (1,0,0) → (0,0,-1) + val t = Mat4.identity().rotateY(PI.toFloat() / 2f).translate(1f, 0f, 0f) + val origin = transformPoint(t, 0f, 0f, 0f) + assertEquals(0f, origin[0], 1e-5f) + assertEquals(0f, origin[1], 1e-5f) + assertEquals(-1f, origin[2], 1e-5f) + + // 各轴缩放系数不同:列号搞混会立刻串味。 + val s = Mat4.identity().rotateY(PI.toFloat() / 2f).scale(2f, 3f, 4f) + val alongX = transformPoint(s, 1f, 0f, 0f) + assertEquals(0f, alongX[0], 1e-5f) + assertEquals(0f, alongX[1], 1e-5f) + assertEquals(-2f, alongX[2], 1e-5f) + val alongY = transformPoint(s, 0f, 1f, 0f) + assertEquals(0f, alongY[0], 1e-5f) + assertEquals(3f, alongY[1], 1e-5f) + assertEquals(0f, alongY[2], 1e-5f) + val alongZ = transformPoint(s, 0f, 0f, 1f) + assertEquals(4f, alongZ[0], 1e-5f) + assertEquals(0f, alongZ[1], 1e-5f) + assertEquals(0f, alongZ[2], 1e-5f) + } + + /** 原地构造器与分配版本是同一份公式:后者只是前者的封装。 */ + @Test + fun `in place builders overwrite every entry`() { + val reused = Mat4.identity().translate(9f, 9f, 9f).scale(7f) + assertArrayEquals(Mat4.rotationY(1.2f).m, reused.setRotationY(1.2f).m, 0f) + assertArrayEquals(Mat4.rotationX(-0.3f).m, reused.setRotationX(-0.3f).m, 0f) + assertArrayEquals(Mat4.rotationZ(2.5f).m, reused.setRotationZ(2.5f).m, 0f) + assertArrayEquals(Mat4.identity().m, reused.setIdentity().m, 0f) + assertArrayEquals(Mat4.translation(1f, 2f, 3f).m, reused.setTranslation(1f, 2f, 3f).m, 0f) + assertArrayEquals(Mat4.scaling(1f, 2f, 3f).m, reused.setScaling(1f, 2f, 3f).m, 0f) + assertArrayEquals( + Mat4.perspective(0.8f, 1.5f, 0.1f, 100f).m, + reused.setPerspective(0.8f, 1.5f, 0.1f, 100f).m, + 0f, + ) + assertArrayEquals( + Mat4.ortho(-2f, 2f, -1f, 1f, -1f, 1f).m, + reused.setOrtho(-2f, 2f, -1f, 1f, -1f, 1f).m, + 0f, + ) + assertArrayEquals( + Mat4.lookAt(0f, 0f, 4f, 0f, 0f, 0f, 0f, 1f, 0f).m, + reused.setLookAt(0f, 0f, 4f, 0f, 0f, 0f, 0f, 1f, 0f).m, + 0f, + ) + } + + @Test + fun `multiply reuses its scratch across calls`() { + // 暂存区是实例字段,连乘与自乘都必须仍然正确。 + val a = Mat4.identity().rotateY(0.5f) + val squared = Mat4.identity().rotateY(0.5f).multiply(a) + assertArrayEquals(Mat4.rotationY(1.0f).m, squared.m, 1e-6f) + + val self = Mat4.identity().rotateY(0.5f) + self.multiply(self) + assertArrayEquals(Mat4.rotationY(1.0f).m, self.m, 1e-6f) + } + + @Test + fun `normal matrix into writes the same values`() { + val rot = Mat4.rotationY(0.9f) + val dest = FloatArray(9) { -1f } + assertArrayEquals( + Mat4.normalMatrix(rot, 0.5f, 2f, 3f), + Mat4.normalMatrixInto(dest, rot, 0.5f, 2f, 3f), + 0f, + ) + } + @Test fun `ortho maps range to clip space`() { val o = Mat4.ortho(-2f, 2f, -1f, 1f, 0f, 10f) diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlAxesTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlAxesTest.kt new file mode 100644 index 0000000..d6138a1 --- /dev/null +++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlAxesTest.kt @@ -0,0 +1,146 @@ +package com.paruh.maxmath.ui.plot.gl + +import com.paruh.maxmath.ui.plot.PlotRange +import com.paruh.maxmath.ui.plot.PlotTicks +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.sqrt + +class PlotGlAxesTest { + + private val range = PlotRange(-5.0, 5.0, -2.0, 6.0) + + private fun vertices(data: FloatArray): List> = + (0 until data.size / 3).map { Triple(data[it * 3], data[it * 3 + 1], data[it * 3 + 2]) } + + @Test + fun `surface box spans the data bounds`() { + val axes = PlotGlAxes.build(range, zMin = -3f, zMax = 7f, kind = GlPlotKind.SURFACE) + // 三条主轴各一条线段,都从 (-1,-1,-1) 这个角出发。 + assertEquals(6, axes.axisX.size) + assertEquals(6, axes.axisY.size) + assertEquals(6, axes.axisZ.size) + assertEquals(Triple(-1f, -1f, -1f), vertices(axes.axisX)[0]) + assertEquals(Triple(1f, -1f, -1f), vertices(axes.axisX)[1]) + assertEquals(Triple(-1f, 1f, -1f), vertices(axes.axisY)[1]) + assertEquals(Triple(-1f, -1f, 1f), vertices(axes.axisZ)[1]) + } + + @Test + fun `frame carries the nine remaining box edges plus tick stubs`() { + val axes = PlotGlAxes.build(range, zMin = -3f, zMax = 7f, kind = GlPlotKind.SURFACE) + assertEquals("线段顶点数必须是 3 的倍数", 0, axes.frame.size % 3) + assertEquals("每条线段两个顶点", 0, axes.frame.size % 6) + val segments = axes.frame.size / 6 + // 9 条棱 + 三个方向的刻度短线 + assertTrue("只有 $segments 条线段,包围盒都不够", segments > 9) + + // 包围盒的棱必须都在 [-1,1]^3 上;刻度短线才允许探到外面。 + val boxEdges = (0 until segments).count { s -> + (0 until 6).all { axes.frame[s * 6 + it] in -1f..1f } + } + assertEquals("包围盒应恰好剩 9 条棱", 9, boxEdges) + } + + @Test + fun `contour has no z axis and stays on the z equals zero plane`() { + val axes = PlotGlAxes.build(range, zMin = 0f, zMax = 1f, kind = GlPlotKind.CONTOUR) + assertEquals(0, axes.axisZ.size) + for (v in vertices(axes.axisX) + vertices(axes.axisY) + vertices(axes.frame)) { + assertEquals("等高线是俯视图,所有线都在 z=0", 0f, v.third, 0f) + } + for (label in axes.labels) { + assertEquals(0f, label.z, 0f) + } + } + + @Test + fun `tick positions match the shared tick algorithm`() { + val axes = PlotGlAxes.build(range, zMin = 0f, zMax = 1f, kind = GlPlotKind.CONTOUR) + // x 轴刻度标签的文本必须来自 PlotTicks——2D 与等高线的刻度要对得上。 + val capacity = PlotTicks.capacity(range.xMin, range.xMax, PlotTicks.TARGET_GL) + val values = DoubleArray(capacity) + val count = PlotTicks.into(values, range.xMin, range.xMax, PlotTicks.TARGET_GL) + val expected = (0 until count).mapNotNull { + val n = 2.0 * (values[it] - range.centerX) / range.width + if (n > -0.999 && n < 0.999) PlotTicks.format(values[it]) else null + }.toSet() + val actual = axes.labels.map { it.text }.toSet() + assertTrue("缺少刻度标签 ${expected - actual}", actual.containsAll(expected)) + } + + @Test + fun `axis names are present per mode`() { + val surface = PlotGlAxes.build(range, -3f, 7f, GlPlotKind.SURFACE).labels.map { it.text } + assertTrue(surface.containsAll(listOf("x", "y", "z"))) + + val contour = PlotGlAxes.build(range, 0f, 1f, GlPlotKind.CONTOUR).labels.map { it.text } + assertTrue(contour.containsAll(listOf("x", "y"))) + assertTrue("等高线没有 z 轴,不该有 z 轴名", !contour.contains("z")) + } + + @Test + fun `numeric labels sit outside the box so the surface cannot cover them`() { + val axes = PlotGlAxes.build(range, -3f, 7f, GlPlotKind.SURFACE) + for (label in axes.labels) { + val outside = label.x < -1f || label.y < -1f + assertTrue("标签 ${label.text} 落在盒子里", outside) + } + } + + @Test + fun `constant surface with zero z span still builds`() { + // 常函数:zMin == zMax,z 轴一个刻度都放不下,但不能崩也不能画废几何。 + val axes = PlotGlAxes.build(range, zMin = 3f, zMax = 3f, kind = GlPlotKind.SURFACE) + assertEquals(0, axes.frame.size % 6) + assertTrue(axes.labels.any { it.text == "z" }) + for (v in axes.frame.toList() + axes.axisX.toList()) { + assertTrue("顶点必须有限", v.isFinite()) + } + } + + /** + * 相机距离与近远平面都是从 [PlotGlRenderer.CONTENT_RADIUS] 推出来的, + * 所以这个半径必须真的兜住全部几何——兜不住就意味着包围盒被视锥切掉, + * 而这正是把「缩放只应用一次」那次改动做出来要消灭的现象。 + * + * 最远的不是盒子的角(√3≈1.732),是画在盒外的 z 轴名。 + */ + @Test + fun `all axis geometry fits inside the camera content radius`() { + val cases = listOf( + Triple(PlotRange(-5.0, 5.0, -2.0, 6.0), -3f, 7f), + Triple(PlotRange(-1.0, 1.0, -1.0, 1.0), -1f, 1f), + Triple(PlotRange(0.0, 1e-3, 0.0, 1e-3), 0f, 1e-3f), + Triple(PlotRange(-1e6, 1e6, -1e6, 1e6), -1e6f, 1e6f), + // 常函数:z 跨度为 0,一个刻度都放不下。 + Triple(PlotRange(-5.0, 5.0, -2.0, 6.0), 3f, 3f), + ) + val limit = PlotGlRenderer.CONTENT_RADIUS + for ((range, zMin, zMax) in cases) { + for (kind in GlPlotKind.values()) { + val axes = PlotGlAxes.build(range, zMin, zMax, kind) + val all = axes.axisX + axes.axisY + axes.axisZ + axes.frame + for (v in vertices(all)) { + val r = sqrt(v.first * v.first + v.second * v.second + v.third * v.third) + assertTrue("$kind $range 顶点 $v 半径 $r 超出 $limit", r <= limit) + } + for (label in axes.labels) { + val r = sqrt(label.x * label.x + label.y * label.y + label.z * label.z) + assertTrue("$kind $range 标签 ${label.text} 锚点半径 $r 超出 $limit", r <= limit) + } + } + } + } + + @Test + fun `ticks flush against the box corners are dropped`() { + // -1..1 上刻度会正好落在 -1 与 1,也就是包围盒的棱上: + // 画上去既看不见又会和轴名叠在一起。 + val unit = PlotRange(-1.0, 1.0, -1.0, 1.0) + val axes = PlotGlAxes.build(unit, -1f, 1f, GlPlotKind.SURFACE) + val numeric = axes.labels.filter { it.text.toDoubleOrNull() != null } + assertTrue("端点刻度应被丢弃", numeric.none { it.text == "-1" || it.text == "1" }) + } +} diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlMeshTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlMeshTest.kt index eb3b5fc..206b405 100644 --- a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlMeshTest.kt +++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlMeshTest.kt @@ -81,6 +81,48 @@ class PlotGlMeshTest { assertTrue(mesh.indices.all { it in 0..0xFFFF }) } + @Test + fun `grid density grows with zoom and stays within the index limit`() { + assertEquals(PlotGlMesh.DEFAULT_GRID, PlotGlMesh.gridFor(1f)) + // 缩小不该让网格比默认还稀疏——那是在给已经看不清的图再降一档。 + assertEquals(PlotGlMesh.DEFAULT_GRID, PlotGlMesh.gridFor(0.5f)) + assertTrue(PlotGlMesh.gridFor(4f) > PlotGlMesh.gridFor(2f)) + assertTrue(PlotGlMesh.gridFor(2f) > PlotGlMesh.gridFor(1f)) + // 手势能到的最大缩放是 8,无论如何都要落在 buildSurface 的合法区间里 + for (zoom in listOf(0.5f, 1f, 2f, 4f, 8f, 100f)) { + val grid = PlotGlMesh.gridFor(zoom) + assertTrue("zoom=$zoom 给出 grid=$grid", grid in 2..PlotGlMesh.MAX_GRID) + } + } + + @Test + fun `mesh at the densest grid still fits unsigned short indices`() { + // gridFor 的上限必须真的能构建出来:索引全部要塞进 unsigned short。 + val mesh = PlotGlMesh.buildSurface( + parse("x+y"), + -1.0, + 1.0, + -1.0, + 1.0, + grid = PlotGlMesh.MAX_GRID, + ) + assertEquals(PlotGlMesh.MAX_GRID * PlotGlMesh.MAX_GRID * 3, mesh.positions.size) + assertTrue(mesh.indices.all { it in 0..0xFFFF }) + } + + @Test + fun `denser grid resolves more of the same surface`() { + // 同一个函数、同一个范围,网格加密后顶点更多、三角形更多—— + // 这正是放大时「细节变多」的来源。 + val coarse = PlotGlMesh.buildSurface(parse("sin(x)*cos(y)"), -3.0, 3.0, -3.0, 3.0, grid = 40) + val fine = PlotGlMesh.buildSurface(parse("sin(x)*cos(y)"), -3.0, 3.0, -3.0, 3.0, grid = 80) + assertTrue(fine.positions.size > coarse.positions.size) + assertTrue(fine.indices.size > coarse.indices.size) + // z 的取值范围应当收敛到同一段,加密不该改变函数本身 + assertEquals(coarse.zMin, fine.zMin, 0.1f) + assertEquals(coarse.zMax, fine.zMax, 0.1f) + } + @Test fun `grid above unsigned short vertex limit is rejected`() { val error = assertThrows(IllegalArgumentException::class.java) { diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlRendererTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlModelsTest.kt similarity index 86% rename from app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlRendererTest.kt rename to app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlModelsTest.kt index 9bb90b8..18d034c 100644 --- a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlRendererTest.kt +++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlModelsTest.kt @@ -4,7 +4,8 @@ import com.paruh.maxmath.ui.plot.PlotRange import org.junit.Assert.assertEquals import org.junit.Test -class PlotGlRendererTest { +/** 原 PlotGlRendererTest:这些矩阵已从渲染器搬到 [PlotGlModels],断言未变。 */ +class PlotGlModelsTest { private fun transformPoint(m: Mat4, x: Float, y: Float, z: Float = 0f): FloatArray { val v = floatArrayOf(x, y, z, 1f) @@ -19,7 +20,7 @@ class PlotGlRendererTest { @Test fun `surface normalize maps data bounds to unit cube`() { val range = PlotRange(-2.0, 2.0, -1.0, 3.0) - val normalize = PlotGlRenderer.surfaceNormalize(range, zMin = -1f, zMax = 3f) + val normalize = PlotGlModels.surfaceNormalize(range, zMin = -1f, zMax = 3f) val minCorner = transformPoint(normalize, -2f, -1f, -1f) val maxCorner = transformPoint(normalize, 2f, 3f, 3f) @@ -38,7 +39,7 @@ class PlotGlRendererTest { @Test fun `contour line model normalizes data range like mesh`() { val range = PlotRange(-2.0, 2.0, -1.0, 3.0) - val model = PlotGlRenderer.contourLineModel(GlViewState(kind = GlPlotKind.CONTOUR), range) + val model = PlotGlModels.contourLineModel(GlViewState(kind = GlPlotKind.CONTOUR), range) val bl = transformPoint(model, -2f, -1f) val tr = transformPoint(model, 2f, 3f) @@ -53,7 +54,7 @@ class PlotGlRendererTest { @Test fun `contour frame model stays in normalized space and applies pan zoom`() { - val model = PlotGlRenderer.contourFrameModel( + val model = PlotGlModels.contourFrameModel( GlViewState(kind = GlPlotKind.CONTOUR, panX = 0.5f, zoom = 2f), ) @@ -65,7 +66,7 @@ class PlotGlRendererTest { @Test fun `contour line model respects pan and zoom`() { val range = PlotRange(-2.0, 2.0, -2.0, 2.0) - val model = PlotGlRenderer.contourLineModel( + val model = PlotGlModels.contourLineModel( GlViewState(kind = GlPlotKind.CONTOUR, panX = 0.5f, zoom = 2f), range, ) diff --git a/docs/OPTIMIZATION.md b/docs/OPTIMIZATION.md index 1c914bc..be809e3 100644 --- a/docs/OPTIMIZATION.md +++ b/docs/OPTIMIZATION.md @@ -310,9 +310,10 @@ adb shell dumpsys gfxinfo com.paruh.maxmath framestats - **R8 / resource shrinking.** Needs keep rules for Chaquopy and JLaTeXMath (both resolve reflectively) plus a signed-release smoke test. Not a blind flip. - **Baseline profile** for Compose startup — none exists. -- **§3e: compile the AST once.** `Evaluator.eval` recurses `Expr` per sample - with a `HashMap` lookup per variable and a fresh `List` per `Expr.Call`. Worth - it only if §3a–§3d leave measurable headroom. +- ~~**§3e: compile the AST once.**~~ Done on `perf/gl-3d-path` as + `parser/…/CompiledExpr.kt`, wired into `PlotGlMesh.evaluateGrid` (the n² grid, + where it actually bites). `Plot2DPainter` still calls `Evaluator`. No timings + yet — this note records where the code landed, not a measured win. - **Engine error strings are hard-coded Chinese** (`"引擎尚未初始化"`, `"计算超时"`) and surface verbatim under English locale, while `app/` correctly uses `R.string`. User-visible, and a natural follow-up to the bilingual README diff --git a/docs/SPEC.md b/docs/SPEC.md index 7c75d17..de3bc98 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -14,7 +14,7 @@ MaxMath 是一款以 GNU Maxima 为符号计算内核、面向 Android 的离线 - 向量空间:内积、范数、Gram-Schmidt 正交化 - 二次型:展开、特征值与符号差 - 微积分:极限、任意阶导数、定积分与不定积分 -- 绘图:2D 多函数、3D 曲面、等高线和触控交互 +- 绘图:2D 多函数、3D 曲面、等高线,带坐标轴刻度与数值标签的触控交互 - 结果:离线 LaTeX 渲染、纯文本/LaTeX 复制、PNG 保存与分享 - 语言:中文、English、跟随系统 @@ -43,8 +43,15 @@ parser 模块提供纯 Kotlin 的词法分析、递归下降解析和 AST。自 进程创建与 Python/Matplotlib 启动的代价。 - 2D 绘图使用同一 AST 生成 NumPy 表达式,由 Maxima 探测零点/极值,再由 Chaquopy 内嵌的 Matplotlib Agg 后端输出 PNG。 -- 3D 曲面和等高线不经过 Maxima/Matplotlib:应用在本地求值 AST、构建网格并使用 - OpenGL ES 渲染,从而让拖动、缩放和旋转保持实时。 +- 3D 曲面和等高线不经过 Maxima/Matplotlib:应用把 AST 编译成定长槽位的表达式树 + (CompiledExpr),在本地逐点求值、构建网格并使用 OpenGL ES 渲染, + 从而让拖动、缩放和旋转保持实时。 +- 手势进行中只更新矩阵,松手才决定是否重建网格:等高线把平移/缩放折算回数据范围 + 并按新范围重新采样,曲面则保持范围不变、按缩放提高网格密度(放大得到的是更多 + 细节,而不是被拉大的多边形)。两者都不惊动引擎,纯本地计算。 +- 曲面与等高线都画出数据边界的包围盒、刻度短线和数值标签。刻度算法与 2D 画布 + 共用同一份实现,因此同一个函数在两种模式下的刻度对得上;标签走 GL 字形图集 + 而不是 Compose 覆盖层,这样「保存 PNG」抓帧缓冲时数字也在图里。 计算请求使用 MathTask 密封类型建模,并通过 JSON 在进程间传递。每类操作拥有独立 数据结构,避免字符串操作名与自由格式 payload 发生漂移。 diff --git a/parser/src/main/kotlin/com/paruh/maxmath/parser/CompiledExpr.kt b/parser/src/main/kotlin/com/paruh/maxmath/parser/CompiledExpr.kt new file mode 100644 index 0000000..0422f80 --- /dev/null +++ b/parser/src/main/kotlin/com/paruh/maxmath/parser/CompiledExpr.kt @@ -0,0 +1,208 @@ +package com.paruh.maxmath.parser + +/** + * 预编译表达式:把 [Expr] 一次性编译成「按下标取变量」的节点树,之后每次求值 + * 只剩虚调用与 double 运算——没有字符串解析、没有哈希查找、没有装箱、零分配。 + * + * 存在的理由是 3D 曲面:默认 120×120 网格要求值 14400 次,而 [Evaluator] 每次 + * 都会重新 `toDoubleOrNull()` 每个数字字面量、给每个变量引用做一次装箱的 + * HashMap 查找、按字符串比较分派运算符、给每个 [Expr.Call] 新建一个 List。 + * 这些成本与采样点数无关,全部可以挪到构建期只付一次。 + * + * 语义与 [Evaluator] 逐位一致(唯一例外见 [Companion.power]), + * `CompiledExprTest` 用一张表逐点比对两者来钉住这一点。 + * + * 本类**不持有可变状态**:变量值由调用方传入的 [DoubleArray] 承载, + * 因此同一个实例可以被多个线程各持一份槽位并发求值。 + */ +class CompiledExpr internal constructor( + private val root: Node, + /** 槽位顺序:[eval] 的 `slots[i]` 对应 `variables[i]`。 */ + val variables: List, +) { + + /** + * 求值。[slots] 必须至少有 [variables] 那么长——用 [newSlots] 分配即可, + * 热路径上不做长度检查。 + */ + fun eval(slots: DoubleArray): Double = root.eval(slots) + + /** 按 [variables] 分配一组槽位。每个求值线程持有自己的一份。 */ + fun newSlots(): DoubleArray = DoubleArray(variables.size) + + companion object { + + /** + * 编译 [expr],把 [variables] 里的名字绑定到对应下标。 + * 不在表内的变量(以及未知常量、未知函数、元数不符的调用)编译成 NaN + * 常量,与 [Evaluator] 对同样输入的返回值一致。 + */ + fun compile(expr: Expr, variables: List): CompiledExpr = + CompiledExpr(node(expr, variables), variables) + + private val NAN = ConstNode(Double.NaN) + + private fun node(expr: Expr, variables: List): Node = when (expr) { + is Expr.Num -> ConstNode(expr.text.toDoubleOrNull() ?: Double.NaN) + is Expr.Var -> { + val index = variables.indexOf(expr.name) + if (index >= 0) SlotNode(index) else NAN + } + is Expr.Const -> ConstNode( + when (expr.name) { + "pi" -> Math.PI + "e" -> Math.E + else -> Double.NaN // "i" 在实值绘图上下文无意义 + }, + ) + is Expr.Unary -> when (expr.op) { + "-" -> negate(node(expr.operand, variables)) + "+" -> node(expr.operand, variables) + else -> NAN + } + is Expr.Binary -> binary( + expr.op, + node(expr.left, variables), + node(expr.right, variables), + ) + is Expr.Call -> callNode(expr, variables) + } + + private fun negate(operand: Node): Node = + if (operand is ConstNode) ConstNode(-operand.value) else NegNode(operand) + + /** 两侧都是常量时直接折叠:`2*pi`、`1/2` 这类子树塌成一次取数。 */ + private fun binary(op: String, left: Node, right: Node): Node { + if (left is ConstNode && right is ConstNode) { + return ConstNode(applyBinary(op, left.value, right.value)) + } + return when (op) { + "+" -> AddNode(left, right) + "-" -> SubNode(left, right) + "*" -> MulNode(left, right) + "/" -> DivNode(left, right) + "^" -> power(left, right) + else -> NAN + } + } + + private fun applyBinary(op: String, a: Double, b: Double): Double = when (op) { + "+" -> a + b + "-" -> a - b + "*" -> a * b + "/" -> a / b + "^" -> Math.pow(a, b) + else -> Double.NaN + } + + /** + * 指数是 0..4 的字面整数时展开成连乘:`x^2+y^2` 是 3D 曲面最常见的写法, + * 而 `Math.pow` 每次都是一趟 libm 调用。 + * + * n=0、n=1 与 `Math.pow` 逐位一致(`pow(x,0)` 恒为 1.0,含 NaN 与无穷; + * `pow(x,1)` 恒为 x)。**n=2..4 是本类唯一允许与 [Evaluator] 不一致的 + * 地方**:`Math.pow` 的规范只保证 1 ulp,连乘可能与它相差 1 ulp。调用方 + * ([com.paruh.maxmath.ui.plot.gl.PlotGlMesh])随后把结果收窄成 Float, + * double 的 1 ulp 比 Float 的精度低约 29 个二进制位,不可见。 + */ + private fun power(base: Node, exponent: Node): Node { + if (exponent is ConstNode) { + val e = exponent.value + // NaN 在这里全部为 false,自然落到通用 Math.pow 分支。 + if (e >= 0.0 && e <= 4.0 && e == Math.floor(e)) { + return when (e.toInt()) { + 0 -> ConstNode(1.0) + 1 -> base + else -> IntPowNode(base, e.toInt()) + } + } + } + return PowNode(base, exponent) + } + + private fun callNode(expr: Expr.Call, variables: List): Node { + val reducer = MathFunctions.reducer(expr.name) + if (reducer != null) { + if (expr.args.isEmpty()) return NAN // 空参数无法求值,同 Evaluator + val args = Array(expr.args.size) { node(expr.args[it], variables) } + if (args.all { it is ConstNode }) { + var acc = (args[0] as ConstNode).value + for (i in 1 until args.size) acc = reducer(acc, (args[i] as ConstNode).value) + return ConstNode(acc) + } + return ReduceNode(reducer, args) + } + val fn = MathFunctions.unary(expr.name) ?: return NAN + if (expr.args.size != 1) return NAN + val arg = node(expr.args[0], variables) + return if (arg is ConstNode) ConstNode(fn(arg.value)) else Call1Node(fn, arg) + } + } +} + +/** + * 编译后的节点。`internal` 而非 `private`:[CompiledExpr] 的 internal 构造函数 + * 以它为参数类型,可见性必须不窄于构造函数。具体节点则是文件私有的。 + */ +internal abstract class Node { + abstract fun eval(slots: DoubleArray): Double +} + +/** [value] 对文件内可见,常量折叠靠它读出子节点的值。 */ +private class ConstNode(val value: Double) : Node() { + override fun eval(slots: DoubleArray): Double = value +} + +private class SlotNode(private val index: Int) : Node() { + override fun eval(slots: DoubleArray): Double = slots[index] +} + +private class NegNode(private val operand: Node) : Node() { + override fun eval(slots: DoubleArray): Double = -operand.eval(slots) +} + +private class AddNode(private val left: Node, private val right: Node) : Node() { + override fun eval(slots: DoubleArray): Double = left.eval(slots) + right.eval(slots) +} + +private class SubNode(private val left: Node, private val right: Node) : Node() { + override fun eval(slots: DoubleArray): Double = left.eval(slots) - right.eval(slots) +} + +private class MulNode(private val left: Node, private val right: Node) : Node() { + override fun eval(slots: DoubleArray): Double = left.eval(slots) * right.eval(slots) +} + +private class DivNode(private val left: Node, private val right: Node) : Node() { + override fun eval(slots: DoubleArray): Double = left.eval(slots) / right.eval(slots) +} + +private class PowNode(private val left: Node, private val right: Node) : Node() { + override fun eval(slots: DoubleArray): Double = Math.pow(left.eval(slots), right.eval(slots)) +} + +/** 见 [CompiledExpr.Companion.power] 关于 1 ulp 的说明。[exponent] 恒在 2..4。 */ +private class IntPowNode(private val base: Node, private val exponent: Int) : Node() { + override fun eval(slots: DoubleArray): Double { + val x = base.eval(slots) + var acc = x + for (i in 2..exponent) acc *= x + return acc + } +} + +private class Call1Node(private val fn: (Double) -> Double, private val arg: Node) : Node() { + override fun eval(slots: DoubleArray): Double = fn(arg.eval(slots)) +} + +/** min/max:NaN 传播在 [MathFunctions.reducer] 内部,这里只负责折叠。 */ +private class ReduceNode( + private val op: (Double, Double) -> Double, + private val args: Array, +) : Node() { + override fun eval(slots: DoubleArray): Double { + var acc = args[0].eval(slots) + for (i in 1 until args.size) acc = op(acc, args[i].eval(slots)) + return acc + } +} diff --git a/parser/src/main/kotlin/com/paruh/maxmath/parser/Evaluator.kt b/parser/src/main/kotlin/com/paruh/maxmath/parser/Evaluator.kt index f87d4f5..12fcf18 100644 --- a/parser/src/main/kotlin/com/paruh/maxmath/parser/Evaluator.kt +++ b/parser/src/main/kotlin/com/paruh/maxmath/parser/Evaluator.kt @@ -6,6 +6,10 @@ package com.paruh.maxmath.parser * * 非有限值(NaN / ±Infinity)直接向上传播,由绘图层负责断线; * 不支持在实值上下文中求值的 `i` 返回 NaN(numpy 中复数转 float 同样失败)。 + * + * 同一棵 AST 要在同一组变量上求值成千上万次时(3D 曲面的 n² 网格)用 + * [CompiledExpr]:它把这里每次都要重做的字面量解析与变量查找挪到构建期。 + * 本类仍是可读的参考实现,两者的数值一致性由 `CompiledExprTest` 钉住。 */ object Evaluator { @@ -37,53 +41,24 @@ object Evaluator { is Expr.Call -> call(expr, vars) } + /** + * 函数公式来自 [MathFunctions],与 [CompiledExpr] 是同一份。 + * + * 参数逐个按需求值,不再先 `args.map { }` 成一个 List:求值是纯函数, + * 元数不符时提前返回 NaN 与「先全求值再判元数」结果相同,但每个 + * [Expr.Call] 少一次装箱 List 分配——2D 那条路径每帧都会走到这里。 + */ private fun call(call: Expr.Call, vars: Map): Double { - val args = call.args.map { eval(it, vars) } - return when (call.name) { - "ln", "log" -> unary(args, Math::log) - "exp" -> unary(args, Math::exp) - "sqrt" -> unary(args, Math::sqrt) - "abs" -> unary(args, Math::abs) - "floor" -> unary(args, Math::floor) - "ceiling" -> unary(args, Math::ceil) - "sin" -> unary(args, Math::sin) - "cos" -> unary(args, Math::cos) - "tan" -> unary(args, Math::tan) - "sinh" -> unary(args, Math::sinh) - "cosh" -> unary(args, Math::cosh) - "tanh" -> unary(args, Math::tanh) - "asin" -> unary(args, Math::asin) - "acos" -> unary(args, Math::acos) - "atan" -> unary(args, Math::atan) - "asinh" -> unary(args) { Math.log(it + Math.sqrt(it * it + 1.0)) } - "acosh" -> unary(args) { Math.log(it + Math.sqrt(it * it - 1.0)) } - "atanh" -> unary(args) { 0.5 * Math.log((1.0 + it) / (1.0 - it)) } - "cot" -> unary(args) { 1.0 / Math.tan(it) } - "sec" -> unary(args) { 1.0 / Math.cos(it) } - "csc" -> unary(args) { 1.0 / Math.sin(it) } - "acot" -> unary(args) { Math.atan(1.0 / it) } - "asec" -> unary(args) { Math.acos(1.0 / it) } - "acsc" -> unary(args) { Math.asin(1.0 / it) } - "coth" -> unary(args) { 1.0 / Math.tanh(it) } - "sech" -> unary(args) { 1.0 / Math.cosh(it) } - "csch" -> unary(args) { 1.0 / Math.sinh(it) } - "acoth" -> unary(args) { 0.5 * Math.log((it + 1.0) / (it - 1.0)) } - "asech" -> unary(args) { Math.log(1.0 / it + Math.sqrt(1.0 / (it * it) - 1.0)) } - "acsch" -> unary(args) { Math.log(1.0 / it + Math.sqrt(1.0 / (it * it) + 1.0)) } - "min" -> variadicReduce(args) { a, b -> minOf(a, b) } - "max" -> variadicReduce(args) { a, b -> maxOf(a, b) } - else -> Double.NaN - } - } - - private fun unary(args: List, f: (Double) -> Double): Double = - if (args.size == 1) f(args[0]) else Double.NaN - - /** numpy 的 minimum/maximum 会传播 NaN;空参数视为无法求值。 */ - private fun variadicReduce(args: List, op: (Double, Double) -> Double): Double { - if (args.isEmpty()) return Double.NaN - return args.reduce { acc, value -> - if (acc.isNaN() || value.isNaN()) Double.NaN else op(acc, value) + val reducer = MathFunctions.reducer(call.name) + if (reducer != null) { + // 空参数视为无法求值;NaN 传播在 reducer 内部。 + if (call.args.isEmpty()) return Double.NaN + var acc = eval(call.args[0], vars) + for (i in 1 until call.args.size) acc = reducer(acc, eval(call.args[i], vars)) + return acc } + val fn = MathFunctions.unary(call.name) ?: return Double.NaN + if (call.args.size != 1) return Double.NaN + return fn(eval(call.args[0], vars)) } } diff --git a/parser/src/main/kotlin/com/paruh/maxmath/parser/MathFunctions.kt b/parser/src/main/kotlin/com/paruh/maxmath/parser/MathFunctions.kt new file mode 100644 index 0000000..2a8a6bf --- /dev/null +++ b/parser/src/main/kotlin/com/paruh/maxmath/parser/MathFunctions.kt @@ -0,0 +1,64 @@ +package com.paruh.maxmath.parser + +/** + * 数学函数表:[Evaluator](AST 解释)与 [CompiledExpr](预编译节点)共用。 + * + * 两条求值路径必须给出完全相同的数值,所以 acosh、acoth、asech 这类没有 + * java.lang.Math 对应项、只能手写公式的函数**只允许在这里出现一次**—— + * 各写一份是它们悄悄分叉的唯一方式。 + * + * 全部写成显式 lambda 而非方法引用:`Math::abs` 有四个重载, + * 依赖期望类型去消歧在这种表里没有可读性上的好处。 + */ +internal object MathFunctions { + + /** 一元函数;未知名字返回 null,调用方按 NaN 处理。 */ + fun unary(name: String): ((Double) -> Double)? = UNARY[name] + + /** + * min/max 的两两合并函数;未知名字返回 null。 + * + * NaN 传播(与 numpy 的 minimum/maximum 一致)写在合并函数**内部**, + * 这样两条求值路径直接 reduce 即可,不必各自复述这条规则。 + */ + fun reducer(name: String): ((Double, Double) -> Double)? = REDUCERS[name] + + private val UNARY: Map Double> = mapOf( + "ln" to { x: Double -> Math.log(x) }, + "log" to { x: Double -> Math.log(x) }, + "exp" to { x: Double -> Math.exp(x) }, + "sqrt" to { x: Double -> Math.sqrt(x) }, + "abs" to { x: Double -> Math.abs(x) }, + "floor" to { x: Double -> Math.floor(x) }, + "ceiling" to { x: Double -> Math.ceil(x) }, + "sin" to { x: Double -> Math.sin(x) }, + "cos" to { x: Double -> Math.cos(x) }, + "tan" to { x: Double -> Math.tan(x) }, + "sinh" to { x: Double -> Math.sinh(x) }, + "cosh" to { x: Double -> Math.cosh(x) }, + "tanh" to { x: Double -> Math.tanh(x) }, + "asin" to { x: Double -> Math.asin(x) }, + "acos" to { x: Double -> Math.acos(x) }, + "atan" to { x: Double -> Math.atan(x) }, + "asinh" to { x: Double -> Math.log(x + Math.sqrt(x * x + 1.0)) }, + "acosh" to { x: Double -> Math.log(x + Math.sqrt(x * x - 1.0)) }, + "atanh" to { x: Double -> 0.5 * Math.log((1.0 + x) / (1.0 - x)) }, + "cot" to { x: Double -> 1.0 / Math.tan(x) }, + "sec" to { x: Double -> 1.0 / Math.cos(x) }, + "csc" to { x: Double -> 1.0 / Math.sin(x) }, + "acot" to { x: Double -> Math.atan(1.0 / x) }, + "asec" to { x: Double -> Math.acos(1.0 / x) }, + "acsc" to { x: Double -> Math.asin(1.0 / x) }, + "coth" to { x: Double -> 1.0 / Math.tanh(x) }, + "sech" to { x: Double -> 1.0 / Math.cosh(x) }, + "csch" to { x: Double -> 1.0 / Math.sinh(x) }, + "acoth" to { x: Double -> 0.5 * Math.log((x + 1.0) / (x - 1.0)) }, + "asech" to { x: Double -> Math.log(1.0 / x + Math.sqrt(1.0 / (x * x) - 1.0)) }, + "acsch" to { x: Double -> Math.log(1.0 / x + Math.sqrt(1.0 / (x * x) + 1.0)) }, + ) + + private val REDUCERS: Map Double> = mapOf( + "min" to { a: Double, b: Double -> if (a.isNaN() || b.isNaN()) Double.NaN else minOf(a, b) }, + "max" to { a: Double, b: Double -> if (a.isNaN() || b.isNaN()) Double.NaN else maxOf(a, b) }, + ) +} diff --git a/parser/src/main/kotlin/com/paruh/maxmath/parser/MathParser.kt b/parser/src/main/kotlin/com/paruh/maxmath/parser/MathParser.kt index 8bb4866..f093aa8 100644 --- a/parser/src/main/kotlin/com/paruh/maxmath/parser/MathParser.kt +++ b/parser/src/main/kotlin/com/paruh/maxmath/parser/MathParser.kt @@ -174,7 +174,12 @@ class MathParser(input: String) { } companion object { - private val FUNCTION_NAMES = setOf( + /** + * 解析器认得的函数名。设为 internal 是给 CompiledExprTest 用的: + * 它要遍历这个集合,确认每个名字在 MathFunctions 里都有实现。 + * 测试自己抄一份的话,新加的函数永远不会被扫到,那条断言就废了。 + */ + internal val FUNCTION_NAMES = setOf( "sin", "cos", "tan", "cot", "sec", "csc", "asin", "acos", "atan", "acot", "asec", "acsc", "sinh", "cosh", "tanh", "coth", "sech", "csch", diff --git a/parser/src/test/kotlin/com/paruh/maxmath/parser/CompiledExprTest.kt b/parser/src/test/kotlin/com/paruh/maxmath/parser/CompiledExprTest.kt new file mode 100644 index 0000000..56498fb --- /dev/null +++ b/parser/src/test/kotlin/com/paruh/maxmath/parser/CompiledExprTest.kt @@ -0,0 +1,183 @@ +package com.paruh.maxmath.parser + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [CompiledExpr] 与 [Evaluator] 的一致性扫描。 + * + * 这是那次编译改造唯一的安全网:编译路径把字面量解析、变量查找、运算符分派 + * 全挪到了构建期,任何一处走样都只会表现为某个函数在某段定义域上悄悄给出 + * 另一个数。所以这里不抽查,而是拿一张表逐点比对,默认要求**逐位相同** + * (NaN 归一化后比较,±0.0 视为不同)。 + * + * 唯一放宽的是 `integer powers` 里 n=2..4 那几例,允许 1 ulp—— + * 理由见 [CompiledExpr] 的 KDoc。 + */ +class CompiledExprTest { + + private val vars = listOf("x", "y") + + /** 覆盖定义域内、边界、定义域外与非有限输入。 */ + private val samples = doubleArrayOf( + 0.0, + -0.0, + 1.0, + -1.0, + 0.5, + -0.5, + 2.0, + -2.0, + 3.7, + -3.7, + 1e-8, + 1e8, + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + ) + + private fun parse(text: String) = MathInputParser.parseExpression(text) + + /** NaN 归一化,±0.0 保持可区分——正是逐位比较想要的语义。 */ + private fun bits(value: Double): Long = java.lang.Double.doubleToLongBits(value) + + private fun assertParity(expr: Expr, label: String) { + val compiled = CompiledExpr.compile(expr, vars) + val slots = compiled.newSlots() + for (x in samples) { + for (y in samples) { + slots[0] = x + slots[1] = y + val expected = Evaluator.eval(expr, mapOf("x" to x, "y" to y)) + val actual = compiled.eval(slots) + assertEquals("$label at x=$x y=$y", bits(expected), bits(actual)) + } + } + } + + private fun assertParity(text: String) = assertParity(parse(text), text) + + private fun assertParityWithinOneUlp(text: String) { + val expr = parse(text) + val compiled = CompiledExpr.compile(expr, vars) + val slots = compiled.newSlots() + for (x in samples) { + for (y in samples) { + slots[0] = x + slots[1] = y + val expected = Evaluator.eval(expr, mapOf("x" to x, "y" to y)) + val actual = compiled.eval(slots) + val label = "$text at x=$x y=$y: $expected vs $actual" + if (expected.isNaN() || actual.isNaN()) { + assertTrue(label, expected.isNaN() && actual.isNaN()) + } else if (expected != actual) { + // 非有限值不给容差:±Infinity 之间 ulp 也是 Infinity, + // 会把符号翻转这类真错误放过去。 + assertTrue(label, expected.isFinite() && actual.isFinite()) + assertTrue(label, Math.abs(expected - actual) <= Math.ulp(expected)) + } + } + } + } + + @Test + fun `every parser function name is covered and agrees`() { + // 直接遍历解析器那份集合,不要在测试里抄一遍:抄一份的话, + // 以后往 MathParser 里加了函数却忘了加实现,这条断言根本扫不到它, + // 一个能打出来的函数就这样悄悄变成 NaN。 + assertTrue("解析器的函数名集合是空的?", MathParser.FUNCTION_NAMES.isNotEmpty()) + for (name in MathParser.FUNCTION_NAMES) { + assertTrue( + "$name 不在 MathFunctions 里", + MathFunctions.unary(name) != null || MathFunctions.reducer(name) != null, + ) + assertTrue("$name 解析器不认识", MathParser.isFunctionName(name)) + assertParity("$name(x)") + } + } + + @Test + fun `arithmetic operators and variables`() { + assertParity("x+y") + assertParity("x-y") + assertParity("x*y") + assertParity("x/y") + assertParity("x^y") + assertParity("-x") + assertParity("+x") + assertParity("-(x+y)") + assertParity("x*y-y/x+2") + } + + @Test + fun `constants and folding`() { + assertParity("pi") + assertParity("e") + assertParity("i") + assertParity("2*pi") + assertParity("1/2") + assertParity("(1+2)^(3-1)") + assertParity("2^3^2") + assertParity("sin(pi/2)*x") + } + + @Test + fun `integer powers`() { + // n=0、n=1 与 Math.pow 逐位一致,仍走严格比较。 + assertParity("x^0") + assertParity("x^1") + // n=2..4 展开成连乘,允许 1 ulp。 + assertParityWithinOneUlp("x^2") + assertParityWithinOneUlp("x^3") + assertParityWithinOneUlp("x^4") + assertParityWithinOneUlp("x^2+y^2") + // 超出展开范围与非整数、负指数都回到 Math.pow,必须逐位一致。 + assertParity("x^5") + assertParity("x^0.5") + assertParity("x^(-2)") + } + + @Test + fun `variadic min and max`() { + assertParity("min(x,y)") + assertParity("max(x,y)") + assertParity("min(x,y,2)") + assertParity("max(x,1,y,-1)") + assertParity("min(x)") + assertParity("min(1,2)") + } + + @Test + fun `degenerate calls degrade to nan exactly like the interpreter`() { + assertParity("sin(x,y)") // 元数不符 + assertParity("min()") // 空参数 + assertParity(Expr.Call("foo", listOf(Expr.Var("x"))), "unknown function") + assertParity(Expr.Call("sin", emptyList()), "sin()") + assertParity(Expr.Unary("?", Expr.Var("x")), "unknown unary op") + assertParity(Expr.Binary("?", Expr.Var("x"), Expr.Var("y")), "unknown binary op") + assertParity(Expr.Num("not-a-number"), "unparsable literal") + assertParity(Expr.Const("tau"), "unknown constant") + } + + @Test + fun `unbound variable is nan`() { + assertParity("z") + assertParity("sin(z)+x") + } + + @Test + fun `slots are positional and the instance holds no state`() { + val compiled = CompiledExpr.compile(parse("x-y"), vars) + assertEquals(listOf("x", "y"), compiled.variables) + assertEquals(2, compiled.newSlots().size) + // 两组独立槽位交替求值:实例本身无可变状态,可被多线程共享。 + val a = doubleArrayOf(5.0, 2.0) + val b = doubleArrayOf(1.0, 10.0) + assertEquals(3.0, compiled.eval(a), 0.0) + assertEquals(-9.0, compiled.eval(b), 0.0) + assertEquals(3.0, compiled.eval(a), 0.0) + } + +} From 084c646e63a135c9828fedb787396a3d97745cf7 Mon Sep 17 00:00:00 2001 From: yueye6811 Date: Sat, 8 Aug 2026 20:46:10 +0800 Subject: [PATCH 2/3] Make the 3D camera framing testable, and correct a wrong claim The previous commit's "known limitation" was wrong, and so was the KDoc on EYE_DISTANCE. Both claimed that in portrait the horizontal field of view is too narrow and corner labels fall off-screen. That reasoning assumed the GL surface fills a portrait screen (aspect ~0.5). It does not: PlotScreen wraps it in fillMaxWidth().aspectRatio(Sizing.PLOT_ASPECT) with PLOT_ASPECT = 3/2, so the aspect is 1.5 regardless of device orientation. Recomputed with the real geometry and real rotations rather than a bounding sphere: at aspect 1.5 the worst orientation needs eye distance 5.11 against the 5.36 in use, and the worst |ndc| over 64,800 orientations is 0.939. Everything is framed, at every orientation. There is nothing to fix. The actual defect was that this was verified by hand, so: - PlotGlCamera holds FOV, CONTENT_RADIUS, EYE_DISTANCE, NEAR/FAR and fovForZoom, out of the renderer's companion where nothing could reach them. - PlotGlCameraTest projects every PlotGlAxes vertex and label anchor through the real Mat4 pipeline at Sizing.PLOT_ASPECT across azimuth 0..360 and elevation 0..180, asserting |ndc| <= 1 and w > 0. It also pins that nothing crosses near/far at any zoom, that magnification is proportional to zoom, and that fovForZoom survives zoom = 0. The test imports Sizing.PLOT_ASPECT rather than hardcoding 1.5, so changing the plot area's aspect ratio now fails the framing test instead of silently cropping the axes. Not compiled -- no JDK or Android SDK here. The Node cross-check that produced the numbers above is the only thing that has actually run. Co-Authored-By: Claude Opus 5 --- .../paruh/maxmath/ui/plot/gl/PlotGlCamera.kt | 64 +++++++++ .../maxmath/ui/plot/gl/PlotGlRenderer.kt | 57 ++------ .../maxmath/ui/plot/gl/PlotGlAxesTest.kt | 4 +- .../maxmath/ui/plot/gl/PlotGlCameraTest.kt | 131 ++++++++++++++++++ 4 files changed, 205 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlCamera.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlCameraTest.kt diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlCamera.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlCamera.kt new file mode 100644 index 0000000..6855c12 --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlCamera.kt @@ -0,0 +1,64 @@ +package com.paruh.maxmath.ui.plot.gl + +import kotlin.math.atan +import kotlin.math.sin +import kotlin.math.tan + +/** + * 3D 曲面的相机参数。 + * + * 单独拆出来,是因为「这些数值到底够不够用」只能靠算,而手算过一次就错过一次: + * 之前按外接球和一个凭空假设的竖屏宽高比推导,结论是角上的标签会出屏, + * 实际上绘图区是 `fillMaxWidth().aspectRatio(3f/2f)`,宽高比恒为 1.5, + * 每个朝向都装得下。现在这些常量连同 [fovForZoom] 都可以在 JVM 上直接投影验证, + * 见 PlotGlCameraTest——那个测试才是这些数字的依据。 + */ +internal object PlotGlCamera { + + /** 竖直视场角。 */ + val FOV_RADIANS = Math.toRadians(45.0).toFloat() + + /** 只是防止除零,不是手势的缩放下限——那个在 GlGestureMath 里。 */ + const val MIN_ZOOM = 0.05f + + /** + * 归一化空间里坐标轴几何到原点的最大距离:包围盒的角是 √3≈1.732, + * 但刻度短线、数值标签和轴名都画在盒外,最远的是 z 轴名的锚点 + * (约 2.028)。取 2.05 留一点余量。 + * + * PlotGlAxesTest 会断言 [PlotGlAxes] 生成的每个顶点和标签锚点都在这个半径内。 + */ + const val CONTENT_RADIUS = 2.05f + + /** + * 相机到原点的距离。**不随 zoom 变**。 + * + * 原来是 `EYE_DISTANCE / zoom`,同时模型又乘了一遍 `scale(zoom)`: + * 缩放被应用了两次(捏合 2 倍实际放大 4 倍),而且相机推近到 zoom≈1.7 + * 时盒子最近的角就穿过近平面被切开了——曲面被切不容易看出来, + * 一条直棱被切非常显眼。现在缩放只由视场角承担,相机固定不动, + * 任何缩放级别都不可能切到几何。 + * + * 半径 R 的球完整落在竖直视场里的条件是 d ≥ R/sin(fov/2)。这是个偏保守的 + * 上界(外接球假设了最坏朝向):在 3:2 的绘图区里,实测最坏朝向只需要 + * 5.11,这里的 5.36 还富余约 5%。 + */ + val EYE_DISTANCE = CONTENT_RADIUS / sin(FOV_RADIANS / 2f) + + /** + * 近远平面贴着内容取,而不是 0.1..100,深度精度好得多。 + * + * 留一成余量:正好取 EYE±R 的话,半径 R 上的点就压在平面上, + * 舍入到哪一侧全看运气。3.10..7.61 相比 0.1..100 依然是巨大的收窄。 + */ + private const val DEPTH_MARGIN = 1.1f + val NEAR_PLANE = EYE_DISTANCE - CONTENT_RADIUS * DEPTH_MARGIN + val FAR_PLANE = EYE_DISTANCE + CONTENT_RADIUS * DEPTH_MARGIN + + /** + * 缩放后的竖直视场角:`tan(fov'/2) = tan(fov/2) / zoom`。 + * 屏幕上的放大倍率因此与 [zoom] 严格成正比。 + */ + fun fovForZoom(zoom: Float): Float = + 2f * atan(tan(FOV_RADIANS / 2f) / zoom.coerceAtLeast(MIN_ZOOM)) +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt index 26fceec..62b28aa 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt @@ -8,10 +8,7 @@ import com.paruh.maxmath.ui.plot.PlotRange import com.paruh.maxmath.ui.theme.GlPalette import java.nio.ByteBuffer import java.nio.ByteOrder -import kotlin.math.atan -import kotlin.math.sin import kotlin.math.sqrt -import kotlin.math.tan enum class GlPlotKind { SURFACE, CONTOUR } @@ -290,14 +287,16 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { private fun updateCamera(s: GlViewState) { val kind = s.kind // 等高线是正交投影,与 zoom 无关:键里固定填 1,免得捏合时白重算。 - val zoomKey = if (kind == GlPlotKind.SURFACE) s.zoom.coerceAtLeast(MIN_PROJECTION_ZOOM) else 1f + val zoomKey = if (kind == GlPlotKind.SURFACE) s.zoom.coerceAtLeast(PlotGlCamera.MIN_ZOOM) else 1f if (kind != projKind || width != projWidth || height != projHeight || zoomKey != projZoom) { val aspect = width.toFloat() / height if (kind == GlPlotKind.SURFACE) { - // 缩放靠收窄视场:tan(fov'/2) = tan(fov/2)/zoom, - // 屏幕上的放大倍率因此与 zoom 严格成正比。 - val halfFov = atan(tan(FOV_RADIANS / 2f) / zoomKey) - projM.setPerspective(halfFov * 2f, aspect, NEAR_PLANE, FAR_PLANE) + projM.setPerspective( + PlotGlCamera.fovForZoom(zoomKey), + aspect, + PlotGlCamera.NEAR_PLANE, + PlotGlCamera.FAR_PLANE, + ) } else { projM.setOrtho(-aspect, aspect, -1f, 1f, -1f, 1f) } @@ -310,7 +309,7 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { rotYM.setRotationY(Math.toRadians(s.azimuthDeg.toDouble()).toFloat()) rotXM.setRotationX(Math.toRadians(s.elevationDeg.toDouble()).toFloat()) // 相机不动。推近相机来缩放会让盒子最近的角穿过近平面被切掉。 - viewM.setLookAt(0f, 0f, EYE_DISTANCE, 0f, 0f, 0f, 0f, 1f, 0f) + viewM.setLookAt(0f, 0f, PlotGlCamera.EYE_DISTANCE, 0f, 0f, 0f, 0f, 1f, 0f) } else { viewM.setIdentity() } @@ -643,46 +642,6 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer { */ private val LIGHT_DIR = normalize(floatArrayOf(0.4f, 0.7f, 0.8f)) private val IDENTITY_MAT3 = floatArrayOf(1f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f) - private val FOV_RADIANS = Math.toRadians(45.0).toFloat() - - /** 只是防止除零,不是手势的缩放下限——那个在 GlGestureMath 里。 */ - private const val MIN_PROJECTION_ZOOM = 0.05f - - /** - * 归一化空间里坐标轴几何到原点的最大距离:包围盒的角是 √3≈1.732, - * 但刻度短线、数值标签和轴名都画在盒外,最远的是 z 轴名的锚点 - * (约 2.028)。取 2.05 留一点余量。 - * - * [PlotGlAxesTest] 会断言 [PlotGlAxes] 生成的每个顶点和标签锚点 - * 都在这个半径内——这个常量是相机参数的依据,不能靠手算维持。 - */ - const val CONTENT_RADIUS = 2.05f - - /** - * 相机到原点的距离。**不随 zoom 变**。 - * - * 原来是 `EYE_DISTANCE / zoom`,同时模型又乘了一遍 `scale(zoom)`: - * 缩放被应用了两次(捏合 2 倍实际放大 4 倍),而且相机推近到 zoom≈1.7 - * 时盒子最近的角就穿过近平面被切开了——曲面被切不容易看出来, - * 一条直棱被切非常显眼。现在缩放只由视场角承担,相机固定不动, - * 任何缩放级别都不可能切到几何。 - * - * 半径 R 的球完整落在**竖直**视场里的条件是 d ≥ R/sin(fov/2)。 - * 竖屏时宽高比小于 1、水平视场比竖直窄,角上的标签仍可能出屏—— - * 那要靠调小 PlotGlAxes 的间距或按宽高比退相机,不在本次改动内。 - */ - private val EYE_DISTANCE = CONTENT_RADIUS / sin(FOV_RADIANS / 2f) - - /** - * 近远平面贴着内容取,而不是 0.1..100,深度精度好得多。 - * - * 留一成余量:正好取 EYE±R 的话,半径 R 上的点就压在平面上, - * 舍入到哪一侧全看运气。3.10..7.61 相比 0.1..100 依然是巨大的收窄。 - */ - private const val DEPTH_MARGIN = 1.1f - private val NEAR_PLANE = EYE_DISTANCE - CONTENT_RADIUS * DEPTH_MARGIN - private val FAR_PLANE = EYE_DISTANCE + CONTENT_RADIUS * DEPTH_MARGIN - private fun normalize(v: FloatArray): FloatArray { val len = sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) return if (len == 0f) { diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlAxesTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlAxesTest.kt index d6138a1..172a595 100644 --- a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlAxesTest.kt +++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlAxesTest.kt @@ -101,7 +101,7 @@ class PlotGlAxesTest { } /** - * 相机距离与近远平面都是从 [PlotGlRenderer.CONTENT_RADIUS] 推出来的, + * 相机距离与近远平面都是从 [PlotGlCamera.CONTENT_RADIUS] 推出来的, * 所以这个半径必须真的兜住全部几何——兜不住就意味着包围盒被视锥切掉, * 而这正是把「缩放只应用一次」那次改动做出来要消灭的现象。 * @@ -117,7 +117,7 @@ class PlotGlAxesTest { // 常函数:z 跨度为 0,一个刻度都放不下。 Triple(PlotRange(-5.0, 5.0, -2.0, 6.0), 3f, 3f), ) - val limit = PlotGlRenderer.CONTENT_RADIUS + val limit = PlotGlCamera.CONTENT_RADIUS for ((range, zMin, zMax) in cases) { for (kind in GlPlotKind.values()) { val axes = PlotGlAxes.build(range, zMin, zMax, kind) diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlCameraTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlCameraTest.kt new file mode 100644 index 0000000..8f33852 --- /dev/null +++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/gl/PlotGlCameraTest.kt @@ -0,0 +1,131 @@ +package com.paruh.maxmath.ui.plot.gl + +import com.paruh.maxmath.ui.plot.PlotRange +import com.paruh.maxmath.ui.theme.Sizing +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.abs +import kotlin.math.tan + +/** + * 相机取景的回归测试。 + * + * 存在的理由很具体:这些参数够不够用,之前是手算的,而且算错了——按外接球加上 + * 一个凭空假设的竖屏宽高比,得出「角上的标签会出屏」的结论,还照此写进了注释。 + * 实际绘图区是 `fillMaxWidth().aspectRatio(3f/2f)`,宽高比恒为 1.5, + * 每个朝向都装得下。这里直接把真几何按真投影算一遍,不再靠人推。 + */ +class PlotGlCameraTest { + + private val range = PlotRange(-5.0, 5.0, -2.0, 6.0) + + /** 与 PlotGlRenderer 逐帧构造的一致:proj * view * (rotY * rotX)。 */ + private fun mvp(aspect: Float, azimuthDeg: Float, elevationDeg: Float, zoom: Float): Mat4 { + val model = Mat4.rotationY(Math.toRadians(azimuthDeg.toDouble()).toFloat()) + .multiply(Mat4.rotationX(Math.toRadians(elevationDeg.toDouble()).toFloat())) + val view = Mat4.lookAt(0f, 0f, PlotGlCamera.EYE_DISTANCE, 0f, 0f, 0f, 0f, 1f, 0f) + val proj = Mat4.perspective( + PlotGlCamera.fovForZoom(zoom), + aspect, + PlotGlCamera.NEAR_PLANE, + PlotGlCamera.FAR_PLANE, + ) + return proj.multiply(view).multiply(model) + } + + /** 返回 (ndcX, ndcY, w)。 */ + private fun projectPoint(m: Mat4, x: Float, y: Float, z: Float): Triple { + val out = FloatArray(4) + for (r in 0 until 4) { + out[r] = m.m[r] * x + m.m[4 + r] * y + m.m[8 + r] * z + m.m[12 + r] + } + return Triple(out[0] / out[3], out[1] / out[3], out[3]) + } + + private fun axisPoints(): List> { + val axes = PlotGlAxes.build(range, zMin = -3f, zMax = 7f, kind = GlPlotKind.SURFACE) + val data = axes.axisX + axes.axisY + axes.axisZ + axes.frame + val points = ArrayList>() + for (i in 0 until data.size / 3) { + points += Triple(data[i * 3], data[i * 3 + 1], data[i * 3 + 2]) + } + for (label in axes.labels) points += Triple(label.x, label.y, label.z) + return points + } + + @Test + fun `every orientation is fully framed at the plot area aspect ratio`() { + val points = axisPoints() + var worstNdc = 0f + var worstAt = "" + // 方位角任意,仰角被 GlGestureMath 夹在 0..180。 + for (az in 0 until 360 step 5) { + for (el in 0..180 step 5) { + val m = mvp(Sizing.PLOT_ASPECT, az.toFloat(), el.toFloat(), zoom = 1f) + for (p in points) { + val (nx, ny, w) = projectPoint(m, p.first, p.second, p.third) + assertTrue("az=$az el=$el 顶点跑到相机背后了", w > 0f) + val worst = maxOf(abs(nx), abs(ny)) + if (worst > worstNdc) { + worstNdc = worst + worstAt = "az=$az el=$el 点=$p" + } + } + } + } + assertTrue("最坏处 |ndc|=$worstNdc 已出画($worstAt)", worstNdc <= 1f) + } + + @Test + fun `nothing crosses the near or far plane at any zoom`() { + val points = axisPoints() + for (zoom in listOf(0.5f, 1f, 2f, 4f, 8f)) { + for (az in 0 until 360 step 15) { + for (el in 0..180 step 15) { + val m = mvp(Sizing.PLOT_ASPECT, az.toFloat(), el.toFloat(), zoom) + for (p in points) { + val (_, _, w) = projectPoint(m, p.first, p.second, p.third) + // 透视投影里 w = -z_view,就是到相机的距离。 + assertTrue("zoom=$zoom 处 w=$w 越过近平面", w >= PlotGlCamera.NEAR_PLANE) + assertTrue("zoom=$zoom 处 w=$w 越过远平面", w <= PlotGlCamera.FAR_PLANE) + } + } + } + } + } + + @Test + fun `magnification is exactly proportional to zoom`() { + // 缩放只应用一次。曾经模型 scale(zoom) 与相机 EYE/zoom 各来一遍, + // 捏合 2 倍实际放大 4 倍。 + val probe = 0.01f + val unit = projectPoint(mvp(Sizing.PLOT_ASPECT, 0f, 0f, 1f), probe, 0f, 0f).first + for (zoom in listOf(0.5f, 2f, 3f, 8f)) { + val got = projectPoint(mvp(Sizing.PLOT_ASPECT, 0f, 0f, zoom), probe, 0f, 0f).first + assertEquals("zoom=$zoom 的放大倍率", unit * zoom, got, abs(unit * zoom) * 1e-5f) + } + } + + @Test + fun `fov narrows with zoom and is clamped against division by zero`() { + assertEquals(PlotGlCamera.FOV_RADIANS, PlotGlCamera.fovForZoom(1f), 1e-6f) + assertTrue(PlotGlCamera.fovForZoom(2f) < PlotGlCamera.fovForZoom(1f)) + assertTrue(PlotGlCamera.fovForZoom(0.5f) > PlotGlCamera.fovForZoom(1f)) + assertTrue("zoom=0 不能变成 NaN 或无穷", PlotGlCamera.fovForZoom(0f).isFinite()) + // tan(fov'/2) = tan(fov/2)/zoom 是这条路径的定义式。 + assertEquals( + tan(PlotGlCamera.FOV_RADIANS / 2f) / 4f, + tan(PlotGlCamera.fovForZoom(4f) / 2f), + 1e-6f, + ) + } + + @Test + fun `the near plane sits in front of the camera`() { + assertTrue(PlotGlCamera.NEAR_PLANE > 0f) + assertTrue(PlotGlCamera.NEAR_PLANE < PlotGlCamera.FAR_PLANE) + // 深度范围只需覆盖内容,不该是 0.1..100 那种浪费精度的写法。 + assertTrue(PlotGlCamera.FAR_PLANE - PlotGlCamera.NEAR_PLANE < 6f) + } +} From b6d925d8e2675719e1973cb9f7a3b4af3347f744 Mon Sep 17 00:00:00 2001 From: yueye6811 Date: Sat, 8 Aug 2026 23:17:28 +0800 Subject: [PATCH 3/3] Release MaxMath 1.1.1 --- README.md | 6 +++--- README.zh-CN.md | 6 +++--- RELEASE_NOTES.md | 19 +++++++++++++++++++ app/build.gradle.kts | 4 ++-- .../maxmath/ui/plot/GlGestureMathTest.kt | 6 ++++-- 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 54772fa..4c1d5d7 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,15 @@ 简体中文

-[Download MaxMath 1.1.0 APK](https://github.com/yueye6811/MaxMath/releases/download/v1.1.0/MaxMath-v1.1.0-arm64-v8a.apk) -· [Release notes](https://github.com/yueye6811/MaxMath/releases/tag/v1.1.0) +[Download MaxMath 1.1.1 APK](https://github.com/yueye6811/MaxMath/releases/download/v1.1.1/MaxMath-v1.1.1-arm64-v8a.apk) +· [Release notes](https://github.com/yueye6811/MaxMath/releases/tag/v1.1.1) MaxMath is an offline Android app for higher-algebra computation and interactive plotting, powered by GNU Maxima. Its interface is built with Kotlin and Jetpack Compose, mathematical input is handled by a pure Kotlin parser, and complex symbolic computations run in a separate engine process. -> Current release: 1.1.0. Minimum supported version: Android 8.0 (API 26). +> Current release: 1.1.1. Minimum supported version: Android 8.0 (API 26). > The native computation engine currently provides an `arm64-v8a` build > workflow only. diff --git a/README.zh-CN.md b/README.zh-CN.md index 49f3cb2..f0e14ee 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -9,14 +9,14 @@ 简体中文

-[下载 MaxMath 1.1.0 APK](https://github.com/yueye6811/MaxMath/releases/download/v1.1.0/MaxMath-v1.1.0-arm64-v8a.apk) -· [发布说明](https://github.com/yueye6811/MaxMath/releases/tag/v1.1.0) +[下载 MaxMath 1.1.1 APK](https://github.com/yueye6811/MaxMath/releases/download/v1.1.1/MaxMath-v1.1.1-arm64-v8a.apk) +· [发布说明](https://github.com/yueye6811/MaxMath/releases/tag/v1.1.1) 基于 GNU Maxima 的离线 Android 高等代数计算与交互式绘图应用。界面使用 Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,复杂符号计算在独立 引擎进程中执行。 -> 当前版本 1.1.0;最低系统 Android 8.0(API 26);原生计算引擎目前仅提供 +> 当前版本 1.1.1;最低系统 Android 8.0(API 26);原生计算引擎目前仅提供 > `arm64-v8a` 构建流程。 ## 功能 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b609dda..65fa93c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,22 @@ +# MaxMath 1.1.1 + +3D/等高线绘图性能、交互与坐标可读性更新(2026-08-08)。 + +## 本版改进 + +- 新增 `CompiledExpr`,把绘图 AST 一次编译为定长变量槽位,3D/等高线网格求值 + 不再为每个采样点重复进行字符串解析、Map 查找和临时列表分配。 +- 等高线 marching squares 改为按单元遍历并按值域剪枝;矩阵变换原地执行, + 相机与投影参数缓存,混合仅在半透明热力图绘制期间启用。 +- 等高线平移/缩放结束后会把手势折算回数据范围并重新采样;3D 放大时保持数据 + 范围不变并提高网格密度,避免拖出空白区域或只放大低精度多边形。 +- 3D 曲面和等高线新增数据边界框、刻度线与数值标签;刻度算法与 2D 共用, + 标签通过 GL 字形图集绘制,因此保存的 PNG 也包含坐标信息。 +- 修复重绘任务互相取消后加载指示无法结束、3D 缩放被重复应用、近远裁剪范围 + 不合理,以及小数零点标签精度不足的问题。 +- 新增表达式编译一致性、刻度、手势反解、矩阵、网格、坐标轴、字形布局和相机 + 取景测试;相机测试会覆盖完整旋转范围并验证所有坐标轴与标签均在视野内。 + # MaxMath 1.1.0 界面、深色模式与交互体验升级版(2026-08-08)。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 83d501c..983e070 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ android { applicationId = "com.paruh.maxmath" minSdk = 26 targetSdk = 36 - versionCode = 16 - versionName = "1.1.0" + versionCode = 17 + versionName = "1.1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/GlGestureMathTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/GlGestureMathTest.kt index fe2cea4..c7a9846 100644 --- a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/GlGestureMathTest.kt +++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/GlGestureMathTest.kt @@ -110,8 +110,10 @@ class GlGestureMathTest { val y = base.yMin + base.height * t / 10.0 val beforeX = norm(x, base.xMin, base.xMax) * moved.zoom + moved.panX val beforeY = norm(y, base.yMin, base.yMax) * moved.zoom + moved.panY - assertEquals(beforeX, norm(x, next.xMin, next.xMax), 1e-9) - assertEquals(beforeY, norm(y, next.yMin, next.yMax), 1e-9) + // panX/panY 是 Float,反解和正算走的是同一组数值,但 float32 + // 存储误差约 2e-7,1e-9 的容差会让这个纯几何恒等式误报。 + assertEquals(beforeX, norm(x, next.xMin, next.xMax), 1e-6) + assertEquals(beforeY, norm(y, next.yMin, next.yMax), 1e-6) } } }