diff --git a/README.md b/README.md
index e1492ca..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.
@@ -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..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` 构建流程。
## 功能
@@ -27,7 +27,7 @@ Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,复杂
- 向量空间:内积、范数、Gram-Schmidt 正交化
- 二次型:展开、特征值与符号差
- 微积分:极限、任意阶导数、定积分与不定积分
-- 绘图:2D 多函数图像、3D 曲面和等高线,支持触控交互
+- 绘图:2D 多函数图像、3D 曲面和等高线,带坐标轴刻度与数值标签,支持触控交互
- 输入:隐式乘法、根号、分数、常用函数、π/e 与自然写法方程组
- 输出:离线 LaTeX 渲染,可复制文本,并可保存或分享 PNG
- 本地化:中文、English、跟随系统
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/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/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/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..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
@@ -25,6 +25,11 @@ data class GlViewState(
/**
* OpenGL ES 2.0 渲染器:绘制 3D 曲面(逐顶点光照)与等高线热力图
* (等值线覆盖)。渲染只在手势状态/网格变化时触发(RENDERMODE_WHEN_DIRTY)。
+ *
+ * 手势期间每一帧都会走一遍这里,所以 [onDrawFrame] 这条路径上**不分配**:
+ * 所有矩阵都是构造一次的字段,逐帧原地重写(见 [Mat4])。相机(旋转、视图、
+ * 投影)每帧只算一次,曲面与坐标轴共用——原先两边各算一遍,且各自读一次
+ * [state],手势正好落在两者之间时还会画出网格与坐标轴对不上的一帧。
*/
internal class PlotGlRenderer : GLSurfaceView.Renderer {
@@ -62,10 +67,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 +108,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 +147,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 +167,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 +198,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 +220,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 +237,123 @@ 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(PlotGlCamera.MIN_ZOOM) else 1f
+ if (kind != projKind || width != projWidth || height != projHeight || zoomKey != projZoom) {
+ val aspect = width.toFloat() / height
+ if (kind == GlPlotKind.SURFACE) {
+ projM.setPerspective(
+ PlotGlCamera.fovForZoom(zoomKey),
+ aspect,
+ PlotGlCamera.NEAR_PLANE,
+ PlotGlCamera.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, PlotGlCamera.EYE_DISTANCE, 0f, 0f, 0f, 0f, 1f, 0f)
+ } else {
+ 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 {
- model = contourLineModel(state, currentRange)
- view = Mat4.identity()
- proj = Mat4.ortho(
- -width.toFloat() / height,
- width.toFloat() / height,
- -1f,
- 1f,
- -1f,
- 1f,
- )
+ 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 +362,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 +375,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 +481,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 t = pixels[topRow + col]
+ pixels[topRow + col] = rgbaToArgb(pixels[bottomRow + col])
+ pixels[bottomRow + col] = rgbaToArgb(t)
+ }
+ top++
+ bottom--
+ }
+ if (top == bottom) {
+ // 高度为奇数时的中间行:只换字节序,不换位置。
+ val middle = top * 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
+ pixels[middle + col] = rgbaToArgb(pixels[middle + col])
}
}
- return Bitmap.createBitmap(dst, width, height, Bitmap.Config.ARGB_8888)
+ 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,7 +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 fun normalize(v: FloatArray): FloatArray {
val len = sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2])
return if (len == 0f) {
@@ -419,39 +651,6 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer {
}
}
- /**
- * 曲面网格归一化:数据坐标 → [-1,1]^3,中心平移到原点。
- * 必须先平移再缩放(S*T),否则中心不会落在原点。
- */
- 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)
- }
-
- /**
- * 等高线边框模型:顶点已是归一化 [-1,1] 坐标,只应用 pan/zoom。
- */
- fun contourFrameModel(state: GlViewState): Mat4 =
- Mat4.identity()
- .translate(state.panX, state.panY, 0f)
- .scale(state.zoom)
-
- /**
- * 等高线等值线模型:顶点是数据坐标,需先归一化(与热力图 mesh 完全一致)。
- */
- 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 VERTEX_SHADER = """
attribute vec3 aPos;
attribute vec3 aNormal;
@@ -462,7 +661,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 +674,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 +702,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..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
@@ -58,4 +58,62 @@ 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
+ // 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)
+ }
+ }
}
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..172a595
--- /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())
+ }
+ }
+
+ /**
+ * 相机距离与近远平面都是从 [PlotGlCamera.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 = PlotGlCamera.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/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)
+ }
+}
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)
+ }
+
+}