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) + } + +}