Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@
<a href="README.zh-CN.md"><strong>简体中文</strong></a>
</p>

[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.

Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
<strong>简体中文</strong>
</p>

[下载 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` 构建流程。

## 功能
Expand All @@ -27,7 +27,7 @@ Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,复杂
- 向量空间:内积、范数、Gram-Schmidt 正交化
- 二次型:展开、特征值与符号差
- 微积分:极限、任意阶导数、定积分与不定积分
- 绘图:2D 多函数图像、3D 曲面和等高线,支持触控交互
- 绘图:2D 多函数图像、3D 曲面和等高线,带坐标轴刻度与数值标签,支持触控交互
- 输入:隐式乘法、根号、分数、常用函数、π/e 与自然写法方程组
- 输出:离线 LaTeX 渲染,可复制文本,并可保存或分享 PNG
- 本地化:中文、English、跟随系统
Expand Down
19 changes: 19 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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)。
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
32 changes: 32 additions & 0 deletions app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
44 changes: 16 additions & 28 deletions app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 实时绘图画布:网格、坐标轴、刻度、曲线、零点/极值标注。
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -214,6 +217,8 @@ object Plot2DPainter {
/**
* 刻度值与其格式化标签。两者只随坐标范围变化,纯平移/缩放的中间帧
* 可以直接复用,避免每帧重算刻度并对每个标签做一次 String.format。
*
* 刻度值本身由 [PlotTicks] 算——3D/等高线的坐标轴用的是同一份。
*/
private class TickCache {
var values: DoubleArray = DoubleArray(0)
Expand All @@ -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])
}
}
}
Expand Down Expand Up @@ -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)
130 changes: 130 additions & 0 deletions app/src/main/java/com/paruh/maxmath/ui/plot/PlotTicks.kt
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading