模組:FunctionGraph/MathEngine
外观
-- Module:FunctionGraph/MathEngine
-- 負責 3D 空間矩陣變換、相機正交/透視投影、曲面法向量光照 (Flat Shading)、高度色譜、畫家演算法 (Z-sorting) 與 3D 座標軸幾何判定
local p = {}
local RenderUtils = require('Module:FunctionGraph/RenderUtils')
local TextRenderer = require('Module:FunctionGraph/TextRenderer')
local sin, cos, sqrt, abs, floor, max, min, rad = math.sin, math.cos, math.sqrt, math.abs, math.floor, math.max, math.min, math.rad
local format, sort = string.format, table.sort
-- 內建科學視覺化色譜控制點 (RGB: 0~255)
local COLORMAPS = {
viridis = {
{ 68, 1, 84 }, { 59, 82, 139 }, { 33, 145, 140 }, { 94, 201, 98 }, { 253, 231, 37 }
},
coolwarm = {
{ 59, 76, 192 }, { 141, 176, 254 }, { 221, 221, 221 }, { 244, 154, 123 }, { 180, 4, 38 }
},
plasma = {
{ 13, 8, 135 }, { 126, 3, 168 }, { 204, 71, 120 }, { 248, 149, 64 }, { 240, 249, 33 }
},
rainbow = {
{ 48, 18, 59 }, { 40, 188, 235 }, { 164, 252, 60 }, { 251, 126, 33 }, { 122, 4, 3 }
}
}
-- 根據正規化高度 t ∈ [0, 1] 與光照強度 shadeFactor 取樣色譜顏色
function p.sampleColormap(cmapName, t, shadeFactor)
t = max(0, min(1, tonumber(t) or 0.5))
local stops = COLORMAPS[string.lower(tostring(cmapName or "viridis"))] or COLORMAPS.viridis
local n = #stops
local scaled = t * (n - 1)
local idx = min(n - 1, floor(scaled) + 1)
local frac = scaled - (idx - 1)
local c0, c1 = stops[idx], stops[idx + 1]
local r = c0[1] + (c1[1] - c0[1]) * frac
local g = c0[2] + (c1[2] - c0[2]) * frac
local b = c0[3] + (c1[3] - c0[3]) * frac
local factor = max(0.20, min(1.40, tonumber(shadeFactor) or 1.0))
if factor <= 1.0 then
r, g, b = r * factor, g * factor, b * factor
else
local boost = factor - 1.0
r = r + (255 - r) * boost
g = g + (255 - g) * boost
b = b + (255 - b) * boost
end
return format("#%02x%02x%02x", max(0, min(255, floor(r + 0.5))), max(0, min(255, floor(g + 0.5))), max(0, min(255, floor(b + 0.5))))
end
-- 質數與質數計數函數語法糖轉換:
-- 將 質數(x) / prime(x) 轉為 primeIndex@Module:Factorization(x)
-- 將 pi(x) / π(x) 轉為 primeIndexOf@Module:Factorization(x)
-- 並回傳是否使用了外部模組 (供自動啟用 useOtherModule = true)
function p.preprocessPrimeSugar(expr_str)
local s = tostring(expr_str or "")
local changed = false
local c1, c2, c3, c4
s, c1 = mw.ustring.gsub(s, "[質质素][數数]%s*%(", "primeIndex@Module:Factorization(")
s, c2 = string.gsub(s, "([^%w_])prime%s*%(", "%1primeIndex@Module:Factorization(")
if string.find(s, "^prime%s*%(") then
s = string.gsub(s, "^prime%s*%(", "primeIndex@Module:Factorization(")
c2 = c2 + 1
end
s, c3 = string.gsub(s, "([^%w_])pi%s*%(", "%1primeIndexOf@Module:Factorization(")
if string.find(s, "^pi%s*%(") then
s = string.gsub(s, "^pi%s*%(", "primeIndexOf@Module:Factorization(")
c3 = c3 + 1
end
s, c4 = mw.ustring.gsub(s, "π%s*%(", "primeIndexOf@Module:Factorization(")
if (c1 + c2 + c3 + c4) > 0 or string.find(s, "@", 1, true) ~= nil then
changed = true
end
return s, changed
end
-- 判斷算式是否為僅在整數定義域有意義的離散函數 (供 samplingMode=auto 自動切換為 integer 取樣)
function p.isDiscreteIntegerExpr(expr_str)
local s = string.lower(tostring(expr_str or ""))
if string.find(s, "primeindex@", 1, true)
or string.find(s, "primeindexof@", 1, true)
or string.find(s, "@module:element", 1, true)
or string.find(s, "@element", 1, true) then
return true
end
return false
end
-- 檢查算式中是否含有指定的獨立變數名 (如 "x", "y", "z")
function p.hasFreeVar(s, varName)
for w in string.gmatch(tostring(s or ""), "[%a_][%w_]*") do
if w == varName then return true end
end
return false
end
-- 拆分頂層方程式 LHS = RHS (忽略 <=, >=, !=, ~=、括號內部等號與含分號之參數式)
function p.splitTopLevelEquation(expr_str)
expr_str = tostring(expr_str or "")
if mw.ustring.find(expr_str, "↦") then return nil, nil end
local depth = 0
local eqPos = nil
local len = #expr_str
for i = 1, len do
local ch = string.sub(expr_str, i, i)
if ch == "(" or ch == "[" or ch == "{" then
depth = depth + 1
elseif ch == ")" or ch == "]" or ch == "}" then
depth = max(0, depth - 1)
elseif depth == 0 then
if ch == ";" then return nil, nil end
if ch == "=" then
local prev = (i > 1) and string.sub(expr_str, i - 1, i - 1) or ""
local nxt = (i < len) and string.sub(expr_str, i + 1, i + 1) or ""
if prev ~= "<" and prev ~= ">" and prev ~= "!" and prev ~= "~" and prev ~= "=" and nxt ~= "=" then
if not eqPos then eqPos = i end
end
end
end
end
if eqPos then
local lhs = mw.text.trim(string.sub(expr_str, 1, eqPos - 1))
local rhs = mw.text.trim(string.sub(expr_str, eqPos + 1))
if lhs ~= "" and rhs ~= "" then
return lhs, rhs
end
end
return nil, nil
end
-- 判斷單一算式是否為 2D 隱函數 (含頂層等號、不含變數 z,且非單純顯函數 y = f(x))
function p.is2DImplicitExpression(expr_str)
local s = mw.text.trim(tostring(expr_str or ""))
if mw.ustring.match(s, "^#[pP][oO][lL][aA][rR]%s*:") or mw.ustring.match(s, "^#[3][dD]%s*:") then
return false
end
local lhs, rhs = p.splitTopLevelEquation(s)
if not lhs or not rhs then return false end
if p.hasFreeVar(s, "z") then return false end
if (lhs == "y" and not p.hasFreeVar(rhs, "y")) or (rhs == "y" and not p.hasFreeVar(lhs, "y")) then
return false
end
return true
end
-- 判斷表達式清單是否屬於 3D 圖表
function p.is3DExpressionList(expr_list, args)
args = args or {}
local modeStr = string.lower(mw.text.trim(tostring(args.type or args.mode or "")))
if modeStr == "3d" or modeStr == "surface" or modeStr == "mesh" or tostring(args["3d"] or "") == "yes" or tostring(args["3d"] or "") == "true" then
return true
end
if not expr_list or #expr_list == 0 then return false end
for i = 1, #expr_list do
local s = mw.text.trim(tostring(expr_list[i] or ""))
if mw.ustring.match(s, "^#[3][dD]%s*:") then
return true
end
local hasLambda = mw.ustring.find(s, "↦") ~= nil
local parts = hasLambda and mw.text.split(s, "\\;") or mw.text.split(s, ";")
-- 6 段分號: 3D 參數曲線; 9 段分號: 3D 參數曲面
if #parts == 6 or #parts >= 9 then
return true
elseif #parts == 1 and not hasLambda then
local lhs, rhs = p.splitTopLevelEquation(s)
if lhs and rhs then
-- 若為方程式 LHS = RHS:只有包含變數 z (如 x^2+y^2+z^2=16 或 z=f(x,y)) 才自動歸為 3D,否則留給 2D 隱函數引擎
if p.hasFreeVar(s, "z") then
return true
end
else
-- 無等號算式:若含獨立變數 y 或 z,視為 3D 顯函數曲面 z = f(x, y)
if p.hasFreeVar(s, "y") or p.hasFreeVar(s, "z") then
return true
end
end
end
end
return false
end
-- 建立 3D 相機與座標投影器
-- 採用標準數學右手座標系:X 軸向右前、Y 軸向深處、Z 軸垂直向上
function p.buildCamera(bounds, plotCenter, plotRadius, opts)
opts = opts or {}
local thetaDeg = tonumber(opts.view_theta or opts.azimuth) or -60
local phiDeg = tonumber(opts.view_phi or opts.elevation) or 28
local rollDeg = tonumber(opts.view_roll or opts.roll) or 0
local projMode = string.lower(tostring(opts.projection or "ortho"))
local camDist = max(2.2, tonumber(opts.view_dist or opts.distance) or 4.5)
local xMin, xMax = bounds.xMin, bounds.xMax
local yMin, yMax = bounds.yMin, bounds.yMax
local zMin, zMax = bounds.zMin, bounds.zMax
local xMid, xSpan = (xMin + xMax) * 0.5, max(1e-9, xMax - xMin)
local yMid, ySpan = (yMin + yMax) * 0.5, max(1e-9, yMax - yMin)
local zMid, zSpan = (zMin + zMax) * 0.5, max(1e-9, zMax - zMin)
-- 決定正規化立方體半邊長 (boxScaleX, boxScaleY, boxScaleZ)
local bx, by, bz = 1.0, 1.0, 0.78
if opts.equal_scale then
local maxSpan = max(xSpan, ySpan, zSpan)
bx = xSpan / maxSpan
by = ySpan / maxSpan
bz = zSpan / maxSpan
elseif opts.box_aspect then
local parts = mw.text.split(tostring(opts.box_aspect), "%s*[,:]%s*")
if #parts >= 3 then
local rx, ry, rz = tonumber(parts[1]) or 1, tonumber(parts[2]) or 1, tonumber(parts[3]) or 0.78
local m = max(rx, ry, rz, 1e-6)
bx, by, bz = rx / m, ry / m, rz / m
end
end
-- 計算旋轉矩陣基底向量
-- 方位角 theta 繞 Z 軸旋轉,仰角 phi 為視線與 XY 平面的夾角
local th = rad(thetaDeg)
local ph = rad(phiDeg)
local rl = rad(rollDeg)
local cosT, sinT = cos(th), sin(th)
local cosP, sinP = cos(ph), sin(ph)
local cosR, sinR = cos(rl), sin(rl)
-- 相機右向量 R、上向量 U、視線深度向量 F (由原點指向相機)
local rx0, ry0, rz0 = -sinT, cosT, 0
local ux0, uy0, uz0 = -sinP * cosT, -sinP * sinT, cosP
local fx, fy, fz = cosP * cosT, cosP * sinT, sinP
local rx = rx0 * cosR + ux0 * sinR
local ry = ry0 * cosR + uy0 * sinR
local rz = rz0 * cosR + uz0 * sinR
local ux = -rx0 * sinR + ux0 * cosR
local uy = -ry0 * sinR + uy0 * cosR
local uz = -rz0 * sinR + uz0 * cosR
local cx, cy = plotCenter[1], plotCenter[2]
local scalePx = plotRadius
-- 將真實數學座標 (x, y, z) 轉為正規化盒座標 (nx, ny, nz)
local function toNorm(x, y, z)
return ((x - xMid) / (xSpan * 0.5)) * bx,
((y - yMid) / (ySpan * 0.5)) * by,
((z - zMid) / (zSpan * 0.5)) * bz
end
-- 將正規化盒座標 (nx, ny, nz) 投影為 2D 畫面座標 (px, py, depth)
-- depth 越大代表離相機越近 (畫家演算法由小排到大)
local function projectNorm(nx, ny, nz)
local camX = nx * rx + ny * ry + nz * rz
local camY = nx * ux + ny * uy + nz * uz
local depth = nx * fx + ny * fy + nz * fz
local persp = 1.0
if projMode == "perspective" or projMode == "persp" then
persp = camDist / max(0.6, camDist - depth)
end
local px = cx + camX * scalePx * persp
local py = cy - camY * scalePx * persp
return px, py, depth
end
local function projectPoint(x, y, z)
local nx, ny, nz = toNorm(x, y, z)
return projectNorm(nx, ny, nz)
end
return {
toNorm = toNorm,
projectNorm = projectNorm,
projectPoint = projectPoint,
viewDir = { fx, fy, fz },
rightDir = { rx, ry, rz },
upDir = { ux, uy, uz },
boxScale = { bx, by, bz },
bounds = bounds
}
end
-- 計算 3D 座標盒的 8 個頂點、自動判定 3 個背景面與 3 條外輪廓刻度軸
function p.buildBoxGeometry(cam)
local bx, by, bz = cam.boxScale[1], cam.boxScale[2], cam.boxScale[3]
local fx, fy, fz = cam.viewDir[1], cam.viewDir[2], cam.viewDir[3]
-- 決定哪三個平面位於背離相機的後方 (供繪製背景牆與格線)
local backX = (fx >= 0) and -bx or bx
local frontX = -backX
local backY = (fy >= 0) and -by or by
local frontY = -backY
local backZ = (fz >= 0) and -bz or bz
local frontZ = -backZ
local b = cam.bounds
local xBackVal = (backX < 0) and b.xMin or b.xMax
local xFrontVal = (frontX < 0) and b.xMin or b.xMax
local yBackVal = (backY < 0) and b.yMin or b.yMax
local yFrontVal = (frontY < 0) and b.yMin or b.yMax
local zBackVal = (backZ < 0) and b.zMin or b.zMax
local zFrontVal = (frontZ < 0) and b.zMin or b.zMax
-- 三個背景平面的四角頂點 (正規化座標)
local backFaces = {
-- 底面 (或頂面) Z = backZ
{
plane = "XY",
zVal = zBackVal,
corners = {
{ -bx, -by, backZ }, { bx, -by, backZ }, { bx, by, backZ }, { -bx, by, backZ }
}
},
-- 後側面 X = backX
{
plane = "YZ",
xVal = xBackVal,
corners = {
{ backX, -by, -bz }, { backX, by, -bz }, { backX, by, bz }, { backX, -by, bz }
}
},
-- 後側面 Y = backY
{
plane = "XZ",
yVal = yBackVal,
corners = {
{ -bx, backY, -bz }, { bx, backY, -bz }, { bx, backY, bz }, { -bx, backY, bz }
}
}
}
-- 挑選最外圍不遮擋圖形的 3 條刻度軸線:
-- X 軸置於 (Y = frontY, Z = backZ) 的前下緣
-- Y 軸置於 (X = frontX, Z = backZ) 的前下緣
-- Z 軸置於左外側輪廓邊 (在 (frontX, backY) 與 (backX, frontY) 中挑選螢幕 X 座標較小或較外側者)
local pZ1x = cam.projectNorm(frontX, backY, 0)
local pZ2x = cam.projectNorm(backX, frontY, 0)
local zAxisX, zAxisY, zTickDirX, zTickDirY
if pZ1x <= pZ2x then
zAxisX, zAxisY = frontX, backY
zTickDirX, zTickDirY = 0, (backY < 0 and -1 or 1)
else
zAxisX, zAxisY = backX, frontY
zTickDirX, zTickDirY = (backX < 0 and -1 or 1), 0
end
return {
backFaces = backFaces,
backX = backX, frontX = frontX,
backY = backY, frontY = frontY,
backZ = backZ, frontZ = frontZ,
xBackVal = xBackVal, xFrontVal = xFrontVal,
yBackVal = yBackVal, yFrontVal = yFrontVal,
zBackVal = zBackVal, zFrontVal = zFrontVal,
axes = {
x = {
yNorm = frontY, zNorm = backZ,
tickVec = { 0, (frontY < 0 and -1 or 1), 0 }
},
y = {
xNorm = frontX, zNorm = backZ,
tickVec = { (frontX < 0 and -1 or 1), 0, 0 }
},
z = {
xNorm = zAxisX, yNorm = zAxisY,
tickVec = { zTickDirX, zTickDirY, 0 }
}
}
}
end
-- 2D 隱函數求解器:Marching Squares (步進正方形) + 鞍點解析 + Regula Falsi 根值細化 + 極點剔除 + 方向感知路徑縫合
function p.evaluateImplicit2D(evalF, xMin, xMax, yMin, yMax, gridN)
local nx = max(40, min(120, tonumber(gridN) or 80))
if nx % 2 == 1 then nx = nx + 1 end -- 保持偶數網格,使對稱區間的原點 (0,0) 與對角線精確落在網格頂點上
local ny = nx
local dx = (xMax - xMin) / nx
local dy = (yMax - yMin) / ny
-- 1. 取樣 (nx+1) x (ny+1) 網格頂點
local V = {}
local X = {}
local Y = {}
for i = 0, nx do
local vx = xMin + i * dx
if abs(vx) < 1e-12 then vx = 0 end
X[i] = vx
V[i] = {}
end
for j = 0, ny do
local vy = yMin + j * dy
if abs(vy) < 1e-12 then vy = 0 end
Y[j] = vy
end
for i = 0, nx do
local vx = X[i]
local col = V[i]
for j = 0, ny do
local val = evalF(vx, Y[j])
if val ~= nil then
-- 對恰好為 0 的頂點施加極微小正擾動,確保對角線交點 (如 x^2=y^2 的 (0,0)) 100% 精確命中頂點且不漏邊
if abs(val) < 1e-11 then val = 1e-11 end
col[j] = val
else
col[j] = false
end
end
end
-- 邊緣異號內插 + 單步 Regula Falsi 細化 + 漸近線極點剔除
local function interpEdge(xA, yA, vA, xB, yB, vB)
local absA, absB = abs(vA), abs(vB)
local denom = absA + absB
if denom < 1e-20 then return 0.5 * (xA + xB), 0.5 * (yA + yB) end
local t = absA / denom
local xm = xA + t * (xB - xA)
local ym = yA + t * (yB - yA)
-- 若其中一端點本身即為零點頂點,直接返回該頂點座標以維持對角線絕對銳利
if absA <= 1e-10 or absB <= 1e-10 then
return xm, ym
end
local vm = evalF(xm, ym)
local maxEnd = max(absA, absB)
-- 若中點函數值不存在或反而比兩端點更大,代表跨越了奇異點/垂直漸近線 (如 tan(x)=tan(y)),予以剔除
if not vm or abs(vm) > maxEnd * 1.05 then
return nil, nil
end
-- 單步 Regula Falsi (試位法) 細化根位置
if (vm > 0) == (vA > 0) then
local d2 = abs(vm) + absB
if d2 > 1e-20 then t = t + (1.0 - t) * (abs(vm) / d2) end
else
local d2 = absA + abs(vm)
if d2 > 1e-20 then t = t * (absA / d2) end
end
return xA + t * (xB - xA), yA + t * (yB - yA)
end
-- 2. 掃描所有網格單元提取等值線段
local rawSegs = {}
local function addSeg(x1, y1, x2, y2)
if x1 and y1 and x2 and y2 then
local d2 = (x2 - x1) ^ 2 + (y2 - y1) ^ 2
if d2 > 1e-18 then
rawSegs[#rawSegs + 1] = { x1, y1, x2, y2 }
end
end
end
for i = 0, nx - 1 do
local x0, x1 = X[i], X[i + 1]
local col0, col1 = V[i], V[i + 1]
for j = 0, ny - 1 do
local y0, y1 = Y[j], Y[j + 1]
local v00, v10, v11, v01 = col0[j], col1[j], col1[j + 1], col0[j + 1]
if v00 and v10 and v11 and v01 then
local b0 = (v00 > 0) and 1 or 0
local b1 = (v10 > 0) and 2 or 0
local b2 = (v11 > 0) and 4 or 0
local b3 = (v01 > 0) and 8 or 0
local mask = b0 + b1 + b2 + b3
if mask > 0 and mask < 15 then
-- 邊編號: e0(底: 00-10), e1(右: 10-11), e2(頂: 01-11), e3(左: 00-01)
local ex, ey = {}, {}
if (b0 > 0) ~= (b1 > 0) then ex[0], ey[0] = interpEdge(x0, y0, v00, x1, y0, v10) end
if (b1 > 0) ~= (b2 > 0) then ex[1], ey[1] = interpEdge(x1, y0, v10, x1, y1, v11) end
if (b3 > 0) ~= (b2 > 0) then ex[2], ey[2] = interpEdge(x0, y1, v01, x1, y1, v11) end
if (b0 > 0) ~= (b3 > 0) then ex[3], ey[3] = interpEdge(x0, y0, v00, x0, y1, v01) end
if mask == 1 or mask == 14 then
addSeg(ex[3], ey[3], ex[0], ey[0])
elseif mask == 2 or mask == 13 then
addSeg(ex[0], ey[0], ex[1], ey[1])
elseif mask == 4 or mask == 11 then
addSeg(ex[1], ey[1], ex[2], ey[2])
elseif mask == 8 or mask == 7 then
addSeg(ex[2], ey[2], ex[3], ey[3])
elseif mask == 3 or mask == 12 then
addSeg(ex[3], ey[3], ex[1], ey[1])
elseif mask == 6 or mask == 9 then
addSeg(ex[0], ey[0], ex[2], ey[2])
elseif mask == 5 or mask == 10 then
-- 鞍點單元 (Saddle Cell):以中心點函數值進行漸近判定 (Asymptotic Decider)
local xc, yc = 0.5 * (x0 + x1), 0.5 * (y0 + y1)
local vc = evalF(xc, yc) or 0
local cornerMax = max(abs(v00), abs(v10), abs(v11), abs(v01))
if abs(vc) <= cornerMax * 1e-3 then
-- 中心點本身為奇異自交點 (如 x^2=y^2),四邊直接交匯於中心點
addSeg(ex[3], ey[3], xc, yc)
addSeg(xc, yc, ex[1], ey[1])
addSeg(ex[0], ey[0], xc, yc)
addSeg(xc, yc, ex[2], ey[2])
elseif (mask == 5 and vc > 0) or (mask == 10 and vc <= 0) then
addSeg(ex[3], ey[3], ex[2], ey[2])
addSeg(ex[0], ey[0], ex[1], ey[1])
else
addSeg(ex[3], ey[3], ex[0], ey[0])
addSeg(ex[1], ey[1], ex[2], ey[2])
end
end
end
end
end
end
-- 3. 方向感知路徑縫合 (Polyline Chaining):將相連短線段合併為連續折線,在交叉點自動保持直線前進
local epsX = max(1e-9, (xMax - xMin) * 1e-5)
local epsY = max(1e-9, (yMax - yMin) * 1e-5)
local function ptKey(px, py)
local kx = floor((px - xMin) / epsX + 0.5)
local ky = floor((py - yMin) / epsY + 0.5)
return kx * 1048576 + ky
end
local adj = {}
for idx, seg in ipairs(rawSegs) do
local k1 = ptKey(seg[1], seg[2])
local k2 = ptKey(seg[3], seg[4])
if not adj[k1] then adj[k1] = {} end
if not adj[k2] then adj[k2] = {} end
adj[k1][#adj[k1] + 1] = { idx = idx, ep = 1 }
adj[k2][#adj[k2] + 1] = { idx = idx, ep = 2 }
end
local visited = {}
-- 挑選與當前行進方向 (dirX, dirY) 夾角最小 (內積最大) 的未訪問相鄰線段
local function pickBestNeighbor(k, dirX, dirY)
local list = adj[k]
if not list then return nil end
local bestItem, bestDot = nil, -2.0
for _, item in ipairs(list) do
if not visited[item.idx] then
local s = rawSegs[item.idx]
local nx_v, ny_v
if item.ep == 1 then
nx_v, ny_v = s[3] - s[1], s[4] - s[2]
else
nx_v, ny_v = s[1] - s[3], s[2] - s[4]
end
local len = sqrt(nx_v * nx_v + ny_v * ny_v)
local dot = (len > 1e-12) and ((dirX * nx_v + dirY * ny_v) / len) or 0
if dot > bestDot then
bestDot = dot
bestItem = item
end
end
end
return bestItem
end
local raw_x, raw_y, x_str, y_str = {}, {}, {}, {}
local ptCount = 0
for idx = 1, #rawSegs do
if not visited[idx] then
visited[idx] = true
local s0 = rawSegs[idx]
local fwdX, fwdY = { s0[1], s0[3] }, { s0[2], s0[4] }
local bwdX, bwdY = {}, {}
-- 向前延伸
while true do
local n = #fwdX
local curX, curY = fwdX[n], fwdY[n]
local dx_c, dy_c = curX - fwdX[n - 1], curY - fwdY[n - 1]
local dl = sqrt(dx_c * dx_c + dy_c * dy_c)
if dl > 1e-12 then dx_c, dy_c = dx_c / dl, dy_c / dl end
local nxt = pickBestNeighbor(ptKey(curX, curY), dx_c, dy_c)
if not nxt then break end
visited[nxt.idx] = true
local ns = rawSegs[nxt.idx]
if nxt.ep == 1 then
fwdX[#fwdX + 1], fwdY[#fwdY + 1] = ns[3], ns[4]
else
fwdX[#fwdX + 1], fwdY[#fwdY + 1] = ns[1], ns[2]
end
end
-- 向後延伸
local prevX, prevY = fwdX[2], fwdY[2]
local headX, headY = fwdX[1], fwdY[1]
while true do
local dx_c, dy_c = headX - prevX, headY - prevY
local dl = sqrt(dx_c * dx_c + dy_c * dy_c)
if dl > 1e-12 then dx_c, dy_c = dx_c / dl, dy_c / dl end
local nxt = pickBestNeighbor(ptKey(headX, headY), dx_c, dy_c)
if not nxt then break end
visited[nxt.idx] = true
prevX, prevY = headX, headY
local ns = rawSegs[nxt.idx]
if nxt.ep == 1 then
headX, headY = ns[3], ns[4]
else
headX, headY = ns[1], ns[2]
end
bwdX[#bwdX + 1], bwdY[#bwdY + 1] = headX, headY
end
-- 若非第一條鏈,先插入 false 斷開標記
if ptCount > 0 then
ptCount = ptCount + 1
raw_x[ptCount], raw_y[ptCount] = false, false
x_str[ptCount], y_str[ptCount] = " ", " "
end
for k = #bwdX, 1, -1 do
ptCount = ptCount + 1
raw_x[ptCount], raw_y[ptCount] = bwdX[k], bwdY[k]
x_str[ptCount], y_str[ptCount] = tostring(bwdX[k]), tostring(bwdY[k])
end
for k = 1, #fwdX do
ptCount = ptCount + 1
raw_x[ptCount], raw_y[ptCount] = fwdX[k], fwdY[k]
x_str[ptCount], y_str[ptCount] = tostring(fwdX[k]), tostring(fwdY[k])
end
end
end
-- 2D 牛頓梯度投影閉包:供 Tooltip 弧長內插點精準貼合隱函數曲線 F(x,y) = 0
local hStep = max(1e-6, min(dx, dy) * 1e-3)
local maxStep = max(dx, dy) * 0.65
local function projectToImplicit2D(qx, qy)
local cx, cy = qx, qy
for _ = 1, 2 do
local f0 = evalF(cx, cy)
if not f0 or abs(f0) < 1e-11 then break end
local fx = evalF(cx + hStep, cy)
local fy = evalF(cx, cy + hStep)
if not fx or not fy then break end
local gx = (fx - f0) / hStep
local gy = (fy - f0) / hStep
local g2 = gx * gx + gy * gy
if g2 < 1e-16 then break end
local stepX = -f0 * gx / g2
local stepY = -f0 * gy / g2
if abs(stepX) > maxStep or abs(stepY) > maxStep then break end
cx = cx + stepX
cy = cy + stepY
end
return cx, cy
end
return raw_x, raw_y, x_str, y_str, projectToImplicit2D
end
-- 3D 隱函數求解器:外擴一層體素之 Surface Nets (平滑四邊形等值面網格) + 三線性梯度牛頓投影
function p.evaluateImplicit3D(evalF, xMin, xMax, yMin, yMax, zMin, zMax, gridN, recordPoint)
local n = max(10, min(28, tonumber(gridN) or 18))
if n % 2 == 1 then n = n + 1 end
local dx = (xMax - xMin) / n
local dy = (yMax - yMin) / n
local dz = (zMax - zMin) / n
-- 外擴 1 層體素 (-1 .. n+1),確保接觸座標盒邊界的開放等值面能完整延伸至盒壁並由 clipPolygonBox 精準削平
local X, Y, Z = {}, {}, {}
for i = -1, n + 1 do
local v = xMin + i * dx
if abs(v) < 1e-12 then v = 0 end
X[i] = v
end
for j = -1, n + 1 do
local v = yMin + j * dy
if abs(v) < 1e-12 then v = 0 end
Y[j] = v
end
for k = -1, n + 1 do
local v = zMin + k * dz
if abs(v) < 1e-12 then v = 0 end
Z[k] = v
end
local V = {}
for i = -1, n + 1 do
local vx = X[i]
local slice = {}
V[i] = slice
for j = -1, n + 1 do
local vy = Y[j]
local col = {}
slice[j] = col
for k = -1, n + 1 do
local val = evalF(vx, vy, Z[k])
if val ~= nil then
if abs(val) < 1e-11 then val = 1e-11 end
col[k] = val
else
col[k] = false
end
end
end
end
-- 1. 為每個發生正負號穿越的體素單元 (cx, cy, cz) 計算等值面頂點並執行牛頓投影
local cellVerts = {}
for cx = 0, n + 1 do
cellVerts[cx] = {}
local x0, x1 = X[cx - 1], X[cx]
local s0, s1 = V[cx - 1], V[cx]
for cy = 0, n + 1 do
cellVerts[cx][cy] = {}
local y0, y1 = Y[cy - 1], Y[cy]
local c00, c10, c01, c11 = s0[cy - 1], s1[cy - 1], s0[cy], s1[cy]
for cz = 0, n + 1 do
local z0, z1 = Z[cz - 1], Z[cz]
local v000, v100 = c00[cz - 1], c10[cz - 1]
local v010, v110 = c01[cz - 1], c11[cz - 1]
local v001, v101 = c00[cz], c10[cz]
local v011, v111 = c01[cz], c11[cz]
if v000 and v100 and v010 and v110 and v001 and v101 and v011 and v111 then
local posCount = ((v000 > 0) and 1 or 0) + ((v100 > 0) and 1 or 0)
+ ((v010 > 0) and 1 or 0) + ((v110 > 0) and 1 or 0)
+ ((v001 > 0) and 1 or 0) + ((v101 > 0) and 1 or 0)
+ ((v011 > 0) and 1 or 0) + ((v111 > 0) and 1 or 0)
if posCount > 0 and posCount < 8 then
local sumX, sumY, sumZ, cnt = 0, 0, 0, 0
local function checkEdge(xa, ya, za, va, xb, yb, zb, vb)
if (va > 0) ~= (vb > 0) then
local t = abs(va) / (abs(va) + abs(vb))
sumX = sumX + xa + t * (xb - xa)
sumY = sumY + ya + t * (yb - ya)
sumZ = sumZ + za + t * (zb - za)
cnt = cnt + 1
end
end
-- 12 條體素邊
checkEdge(x0, y0, z0, v000, x1, y0, z0, v100)
checkEdge(x0, y1, z0, v010, x1, y1, z0, v110)
checkEdge(x0, y0, z1, v001, x1, y0, z1, v101)
checkEdge(x0, y1, z1, v011, x1, y1, z1, v111)
checkEdge(x0, y0, z0, v000, x0, y1, z0, v010)
checkEdge(x1, y0, z0, v100, x1, y1, z0, v110)
checkEdge(x0, y0, z1, v001, x0, y1, z1, v011)
checkEdge(x1, y0, z1, v101, x1, y1, z1, v111)
checkEdge(x0, y0, z0, v000, x0, y0, z1, v001)
checkEdge(x1, y0, z0, v100, x1, y0, z1, v101)
checkEdge(x0, y1, z0, v010, x0, y1, z1, v011)
checkEdge(x1, y1, z0, v110, x1, y1, z1, v111)
if cnt > 0 then
local px, py, pz = sumX / cnt, sumY / cnt, sumZ / cnt
local fc = evalF(px, py, pz)
local maxCorner = max(abs(v000), abs(v100), abs(v010), abs(v110), abs(v001), abs(v101), abs(v011), abs(v111))
-- 剔除跨越奇異極點的假面
if fc and abs(fc) <= maxCorner * 1.15 then
-- 利用 8 頂點差分計算三線性梯度 ∇F 並執行牛頓投影
local gx = ((v100 - v000) + (v110 - v010) + (v101 - v001) + (v111 - v011)) / (4 * dx)
local gy = ((v010 - v000) + (v110 - v100) + (v011 - v001) + (v111 - v101)) / (4 * dy)
local gz = ((v001 - v000) + (v101 - v100) + (v011 - v010) + (v111 - v110)) / (4 * dz)
local g2 = gx * gx + gy * gy + gz * gz
if g2 > 1e-14 then
px = max(x0, min(x1, px - fc * gx / g2))
py = max(y0, min(y1, py - fc * gy / g2))
pz = max(z0, min(z1, pz - fc * gz / g2))
end
cellVerts[cx][cy][cz] = { px, py, pz }
if recordPoint and cx >= 1 and cx <= n and cy >= 1 and cy <= n and cz >= 1 and cz <= n then
recordPoint(
max(xMin, min(xMax, px)),
max(yMin, min(yMax, py)),
max(zMin, min(zMax, pz))
)
end
end
end
end
end
end
end
end
-- 2. 對每條發生正負號穿越的網格邊,串聯共享該邊的 4 個體素頂點構成平滑四邊形 (Quad)
local quads = {}
-- (a) X 方向網格邊
for ix = 1, n + 1 do
local s0, s1 = V[ix - 1], V[ix]
local cv = cellVerts[ix]
for iy = 0, n do
local c0, c1 = s0[iy], s1[iy]
local cv0, cv1 = cv[iy], cv[iy + 1]
for iz = 0, n do
local va, vb = c0[iz], c1[iz]
if va and vb and ((va > 0) ~= (vb > 0)) then
local p00, p10, p11, p01 = cv0[iz], cv1[iz], cv1[iz + 1], cv0[iz + 1]
if p00 and p10 and p11 and p01 then
quads[#quads + 1] = { p00, p10, p11, p01 }
end
end
end
end
end
-- (b) Y 方向網格邊
for ix = 0, n do
local s = V[ix]
local cvA, cvB = cellVerts[ix], cellVerts[ix + 1]
for iy = 1, n + 1 do
local c0, c1 = s[iy - 1], s[iy]
for iz = 0, n do
local va, vb = c0[iz], c1[iz]
if va and vb and ((va > 0) ~= (vb > 0)) then
local p00, p10, p11, p01 = cvA[iy][iz], cvA[iy][iz + 1], cvB[iy][iz + 1], cvB[iy][iz]
if p00 and p10 and p11 and p01 then
quads[#quads + 1] = { p00, p10, p11, p01 }
end
end
end
end
end
-- (c) Z 方向網格邊
for ix = 0, n do
local s = V[ix]
local cvA, cvB = cellVerts[ix], cellVerts[ix + 1]
for iy = 0, n do
local col = s[iy]
for iz = 1, n + 1 do
local va, vb = col[iz - 1], col[iz]
if va and vb and ((va > 0) ~= (vb > 0)) then
local p00, p10, p11, p01 = cvA[iy][iz], cvB[iy][iz], cvB[iy + 1][iz], cvA[iy + 1][iz]
if p00 and p10 and p11 and p01 then
quads[#quads + 1] = { p00, p10, p11, p01 }
end
end
end
end
end
-- 3D 牛頓梯度投影閉包 (供 Tooltip 重心內插後精確落於 F(x,y,z)=0 曲面上)
local hStep = max(1e-6, min(dx, dy, dz) * 1e-3)
local maxStep = max(dx, dy, dz) * 0.65
local function projectToImplicit3D(qx, qy, qz)
local cx, cy, cz = qx, qy, qz
for _ = 1, 2 do
local f0 = evalF(cx, cy, cz)
if not f0 or abs(f0) < 1e-11 then break end
local fx = evalF(cx + hStep, cy, cz)
local fy = evalF(cx, cy + hStep, cz)
local fz = evalF(cx, cy, cz + hStep)
if not fx or not fy or not fz then break end
local gx, gy, gz = (fx - f0) / hStep, (fy - f0) / hStep, (fz - f0) / hStep
local g2 = gx * gx + gy * gy + gz * gz
if g2 < 1e-16 then break end
local sx, sy, sz = -f0 * gx / g2, -f0 * gy / g2, -f0 * gz / g2
if abs(sx) > maxStep or abs(sy) > maxStep or abs(sz) > maxStep then break end
cx, cy, cz = cx + sx, cy + sy, cz + sz
end
return cx, cy, cz
end
return quads, projectToImplicit3D
end
-- 執行 3D 函數與參數式取樣運算
function p.evaluate3DSeries(expr_list, args, lib_calc, mathlib, numberConstructer, rmath)
args = args or {}
local x_start = tonumber(args.x_start or args.start or args.xstart) or -3
local x_end = tonumber(args.x_end or args["end"] or args.xend) or 3
local y_start = tonumber(args.y_start or args.ystart) or x_start
local y_end = tonumber(args.y_end or args.yend) or x_end
local z_min_clamp = tonumber(args.z_min or args.min)
local z_max_clamp = tonumber(args.z_max or args.max)
-- 3D 曲面預設取樣 24x24 (576 個網格面),兼顧細膩度與 <0.08 秒極速渲染
local rawSampling = tonumber(args.sampling)
local surfSampling = max(8, min(42, rawSampling or 24))
local curveSampling = max(30, min(400, rawSampling or 120))
local series3D = {}
local check_func = {}
local eval_env = { x = 0, y = 0, z = 0 }
local param_env = {}
local noop = function() end
local function evalPostfixReal(pf, env)
local val = nil
xpcall(function()
local res = lib_calc.calc_by_postfix(pf, env, mathlib, numberConstructer, false)
if res and res ~= " " then
local nr = mathlib.nonRealPart and mathlib.nonRealPart(res)
if nr and abs(tonumber(mathlib.abs(nr)) or 0) > 1e-8 then
res = nil
end
if res then
local re = tonumber(mathlib.re(res))
if re and re == re and abs(re) < 1e12 then
val = re
end
end
end
end, noop)
return val
end
local dataXMin, dataXMax = math.huge, -math.huge
local dataYMin, dataYMax = math.huge, -math.huge
local dataZMin, dataZMax = math.huge, -math.huge
local function recordPoint(vx, vy, vz)
if vx < dataXMin then dataXMin = vx end
if vx > dataXMax then dataXMax = vx end
if vy < dataYMin then dataYMin = vy end
if vy > dataYMax then dataYMax = vy end
if vz < dataZMin then dataZMin = vz end
if vz > dataZMax then dataZMax = vz end
end
for i = 1, #expr_list do
local expr_str = mw.text.trim(tostring(expr_list[i] or ""))
local body3d = mw.ustring.match(expr_str, "^#[3][dD]%s*:%s*(.+)$")
if body3d then expr_str = mw.text.trim(body3d) end
local hasLambda = mw.ustring.find(expr_str, "↦") ~= nil
local parts = hasLambda and mw.text.split(expr_str, "\\;") or mw.text.split(expr_str, ";")
local custom_name = args[tostring(i) .. " name"] or args["y" .. i .. "Title"]
if #parts == 6 then
-- 3D 空間參數曲線: x(t); y(t); z(t); t; t_min; t_max
local x_name = mw.text.trim(parts[1] or "t")
local y_name = mw.text.trim(parts[2] or "t")
local z_name = mw.text.trim(parts[3] or "t")
local t_var = mw.text.trim(parts[4] or "t")
local t_min = tonumber(tostring(numberConstructer(parts[5]) or 0)) or 0
local t_max = tonumber(tostring(numberConstructer(parts[6]) or 6.2831853)) or 6.2831853
local pfX = lib_calc.infixToPostfix(lib_calc._function_preprocessing(x_name, mathlib, numberConstructer, false))
local pfY = lib_calc.infixToPostfix(lib_calc._function_preprocessing(y_name, mathlib, numberConstructer, false))
local pfZ = lib_calc.infixToPostfix(lib_calc._function_preprocessing(z_name, mathlib, numberConstructer, false))
local pts = {}
local dt = (t_max - t_min) / curveSampling
for k = 0, curveSampling do
local t_val = t_min + k * dt
param_env[t_var] = t_val
local vx = evalPostfixReal(pfX, param_env)
local vy = evalPostfixReal(pfY, param_env)
local vz = evalPostfixReal(pfZ, param_env)
param_env[t_var] = nil
if vx and vy and vz then
if z_min_clamp and vz < z_min_clamp then vz = z_min_clamp end
if z_max_clamp and vz > z_max_clamp then vz = z_max_clamp end
pts[k + 1] = { vx, vy, vz }
recordPoint(vx, vy, vz)
else
pts[k + 1] = false
end
end
-- (1) 3D 空間參數曲線分支結尾:
local title = tostring(custom_name or ("(" .. x_name .. ", " .. y_name .. ", " .. z_name .. ")"))
series3D[#series3D + 1] = {
kind = "curve3d",
points = pts,
t_min = t_min,
t_max = t_max,
eval_fn = function(t_q)
param_env[t_var] = t_q
local vx = evalPostfixReal(pfX, param_env)
local vy = evalPostfixReal(pfY, param_env)
local vz = evalPostfixReal(pfZ, param_env)
param_env[t_var] = nil
if vx and vy and vz then
if z_min_clamp and vz < z_min_clamp then vz = z_min_clamp end
if z_max_clamp and vz > z_max_clamp then vz = z_max_clamp end
return vx, vy, vz
end
return nil
end,
title = title,
raw_expr = expr_str
}
elseif #parts >= 9 then
-- 3D 參數曲面: x(u,v); y(u,v); z(u,v); u; u_min; u_max; v; v_min; v_max
local x_name = mw.text.trim(parts[1] or "u")
local y_name = mw.text.trim(parts[2] or "v")
local z_name = mw.text.trim(parts[3] or "0")
local u_var = mw.text.trim(parts[4] or "u")
local u_min = tonumber(tostring(numberConstructer(parts[5]) or 0)) or 0
local u_max = tonumber(tostring(numberConstructer(parts[6]) or 6.2831853)) or 6.2831853
local v_var = mw.text.trim(parts[7] or "v")
local v_min = tonumber(tostring(numberConstructer(parts[8]) or 0)) or 0
local v_max = tonumber(tostring(numberConstructer(parts[9]) or 6.2831853)) or 6.2831853
local pfX = lib_calc.infixToPostfix(lib_calc._function_preprocessing(x_name, mathlib, numberConstructer, false))
local pfY = lib_calc.infixToPostfix(lib_calc._function_preprocessing(y_name, mathlib, numberConstructer, false))
local pfZ = lib_calc.infixToPostfix(lib_calc._function_preprocessing(z_name, mathlib, numberConstructer, false))
local nu = tonumber(args.uSampling) or surfSampling
local nv = tonumber(args.vSampling) or surfSampling
local du = (u_max - u_min) / nu
local dv = (v_max - v_min) / nv
local grid = {}
for ru = 0, nu do
local u_val = u_min + ru * du
param_env[u_var] = u_val
local row = {}
for cv = 0, nv do
local v_val = v_min + cv * dv
param_env[v_var] = v_val
local vx = evalPostfixReal(pfX, param_env)
local vy = evalPostfixReal(pfY, param_env)
local vz = evalPostfixReal(pfZ, param_env)
if vx and vy and vz then
if z_min_clamp and vz < z_min_clamp then vz = z_min_clamp end
if z_max_clamp and vz > z_max_clamp then vz = z_max_clamp end
row[cv + 1] = { vx, vy, vz }
recordPoint(vx, vy, vz)
else
row[cv + 1] = false
end
end
grid[ru + 1] = row
end
param_env[u_var] = nil
param_env[v_var] = nil
-- (2) 3D 參數曲面分支結尾:
local title = tostring(custom_name or ("(" .. x_name .. ", " .. y_name .. ", " .. z_name .. ")"))
series3D[#series3D + 1] = {
kind = "surface3d",
grid = grid,
nu = nu,
nv = nv,
u_min = u_min, u_max = u_max,
v_min = v_min, v_max = v_max,
eval_fn = function(u_q, v_q)
param_env[u_var] = u_q
param_env[v_var] = v_q
local vx = evalPostfixReal(pfX, param_env)
local vy = evalPostfixReal(pfY, param_env)
local vz = evalPostfixReal(pfZ, param_env)
param_env[u_var] = nil
param_env[v_var] = nil
if vx and vy and vz then
if z_min_clamp and vz < z_min_clamp then vz = z_min_clamp end
if z_max_clamp and vz > z_max_clamp then vz = z_max_clamp end
return vx, vy, vz
end
return nil
end,
title = title,
raw_expr = expr_str,
parametric = true
}
else
-- 檢查是否為 3D 隱函數方程式 F(x, y, z) = 0 或顯函數 z = f(x, y)
local lhs, rhs = p.splitTopLevelEquation(expr_str)
local isImplicit3D = false
local eval_expr_str = expr_str
if lhs and rhs then
if lhs == "z" and not p.hasFreeVar(rhs, "z") then
eval_expr_str = rhs
elseif rhs == "z" and not p.hasFreeVar(lhs, "z") then
eval_expr_str = lhs
else
isImplicit3D = true
eval_expr_str = (rhs == "0") and lhs or ("(" .. lhs .. ")-(" .. rhs .. ")")
end
end
local pre_expr, pre_scope = lib_calc._function_preprocessing(eval_expr_str, mathlib, numberConstructer, false)
local pf = lib_calc.infixToPostfix(pre_expr)
if type(pf) == "table" and pre_scope then pf.scope = pre_scope end
if isImplicit3D then
local z_imp_min = tonumber(args.zAxisMin or args.z_min or args.min or args.zstart) or x_start
local z_imp_max = tonumber(args.zAxisMax or args.z_max or args.max or args.zend) or x_end
local impSampling = tonumber(args.implicitSampling or args.sampling) or 18
local quads, projFn = p.evaluateImplicit3D(
function(vx, vy, vz)
eval_env.x, eval_env.y, eval_env.z = vx, vy, vz
return evalPostfixReal(pf, eval_env)
end,
x_start, x_end, y_start, y_end, z_imp_min, z_imp_max, impSampling, recordPoint
)
local yTitle = tostring(custom_name or expr_str)
if check_func[yTitle] then
check_func[yTitle] = check_func[yTitle] + 1
yTitle = yTitle .. " ,(" .. tostring(check_func[yTitle]) .. ")"
else
check_func[yTitle] = 1
end
series3D[#series3D + 1] = {
kind = "surface3d",
is_implicit3d = true,
quads = quads,
project_fn = projFn,
title = yTitle,
raw_expr = expr_str,
parametric = false
}
else
local nx = tonumber(args.xSampling) or surfSampling
local ny = tonumber(args.ySampling) or surfSampling
local dx = (x_end - x_start) / nx
local dy = (y_end - y_start) / ny
local grid = {}
for rx = 0, nx do
local vx = x_start + rx * dx
if abs(vx) < 1e-12 then vx = 0 end
eval_env.x = vx
local row = {}
for cy = 0, ny do
local vy = y_start + cy * dy
if abs(vy) < 1e-12 then vy = 0 end
eval_env.y = vy
local vz = evalPostfixReal(pf, eval_env)
if vz then
if z_min_clamp and vz < z_min_clamp then vz = z_min_clamp end
if z_max_clamp and vz > z_max_clamp then vz = z_max_clamp end
row[cy + 1] = { vx, vy, vz }
recordPoint(vx, vy, vz)
else
row[cy + 1] = false
end
end
grid[rx + 1] = row
end
local defaultZTitle = (lhs == "z" or rhs == "z") and expr_str or ("z=" .. expr_str)
local yTitle = tostring(custom_name or defaultZTitle)
if check_func[yTitle] then
check_func[yTitle] = check_func[yTitle] + 1
yTitle = yTitle .. " ,(" .. tostring(check_func[yTitle]) .. ")"
else
check_func[yTitle] = 1
end
series3D[#series3D + 1] = {
kind = "surface3d",
grid = grid,
nu = nx,
nv = ny,
u_min = x_start, u_max = x_end,
v_min = y_start, v_max = y_end,
eval_fn = function(x_q, y_q)
eval_env.x = x_q
eval_env.y = y_q
local vz = evalPostfixReal(pf, eval_env)
if vz then
if z_min_clamp and vz < z_min_clamp then vz = z_min_clamp end
if z_max_clamp and vz > z_max_clamp then vz = z_max_clamp end
return x_q, y_q, vz
end
return nil
end,
title = yTitle,
raw_expr = expr_str,
parametric = false
}
end
end
end
-- 整理 X, Y, Z 邊界
local xMin = tonumber(args.xAxisMin) or (dataXMin < math.huge and dataXMin or x_start)
local xMax = tonumber(args.xAxisMax) or (dataXMax > -math.huge and dataXMax or x_end)
local yMin = tonumber(args.yAxisMin) or (dataYMin < math.huge and dataYMin or y_start)
local yMax = tonumber(args.yAxisMax) or (dataYMax > -math.huge and dataYMax or y_end)
local zMin = tonumber(args.zAxisMin) or (dataZMin < math.huge and dataZMin or -1)
local zMax = tonumber(args.zAxisMax) or (dataZMax > -math.huge and dataZMax or 1)
if xMin == xMax then xMin, xMax = xMin - 1, xMax + 1 end
if yMin == yMax then yMin, yMax = yMin - 1, yMax + 1 end
if zMin == zMax then zMin, zMax = zMin - 1, zMax + 1 end
-- 為 Z 軸上下預留 5% 緩衝空間讓曲面頂點不貼死頂蓋
if not tonumber(args.zAxisMin) and not tonumber(args.zAxisMax) then
local zSpan = zMax - zMin
zMin = zMin - zSpan * 0.05
zMax = zMax + zSpan * 0.05
end
return {
is_3d = true,
series = series3D,
bounds = {
xMin = xMin, xMax = xMax,
yMin = yMin, yMax = yMax,
zMin = zMin, zMax = zMax,
dataZMin = (dataZMin < math.huge) and dataZMin or zMin,
dataZMax = (dataZMax > -math.huge) and dataZMax or zMax
}
}
end
-- 將算式中的獨立複變數 z 正規化為內部運算變數 x (保留 exp/zeta 等函數名不受影響)
local function normalizeComplexVarZ(expr_str)
local hasX = false
local hasZ = false
for word in string.gmatch(expr_str, "[%a_][%w_]*") do
if word == "x" then hasX = true
elseif word == "z" then hasZ = true end
end
if hasZ and not hasX then
expr_str = string.gsub(expr_str, "[%a_][%w_]*", function(w)
return (w == "z") and "x" or w
end)
end
return expr_str
end
-- 執行 3D 複變函數模態曲面取樣:水平面為 (Re(z), Im(z)),高度 Z 為範數 |f(z)|,顏色為輻角 arg(f(z)) 最高彩度純色相
function p.evaluateComplex3DSeries(expr_list, args, lib_calc, mathlib, numberConstructer)
args = args or {}
local x_start = tonumber(args.x_start or args.xstart or args.start) or -5
local x_end = tonumber(args.x_end or args.xend or args["end"]) or 5
local y_start = tonumber(args.y_start or args.ystart) or x_start
local y_end = tonumber(args.y_end or args.yend) or x_end
local z_min_user = tonumber(args.zAxisMin or args.z_min)
local z_max_user = tonumber(args.zAxisMax or args.z_max)
local rawSampling = tonumber(args.sampling)
local surfSampling = max(10, min(42, (rawSampling and rawSampling <= 45) and rawSampling or 26))
local nx = tonumber(args.xSampling) or surfSampling
local ny = tonumber(args.ySampling) or surfSampling
local i_value = (mathlib.elements and mathlib.elements[2]) or mathlib.i
if not i_value then error("繪製失敗:所用數體無非實數單位元") end
local zero_elem = mathlib[0]
local atan2 = math.atan2
local rad_to_deg = 180 / math.pi
local noop = function() end
-- 展開輸入算式清單 (預設將方程式 f(z) = g(z) 移項為單一零點差值曲面 f(z) - g(z),維持 1 對 1 圖例)
local expanded_list = {}
local implicitMode = string.lower(mw.text.trim(tostring(args.implicit_mode or args.implicitMode or "diff")))
for idx = 1, #expr_list do
local raw_s = mw.text.trim(tostring(expr_list[idx] or ""))
local body3d = mw.ustring.match(raw_s, "^#[3][dD]%s*:%s*(.+)$")
if body3d then raw_s = mw.text.trim(body3d) end
local custom_name = args[tostring(idx) .. " name"] or args["y" .. idx .. "Title"]
local lhs, rhs = p.splitTopLevelEquation(raw_s)
if lhs and rhs then
if implicitMode == "split" and rhs ~= "0" then
expanded_list[#expanded_list + 1] = {
expr = lhs,
title = custom_name and (custom_name .. " (LHS)") or lhs,
raw_expr = lhs
}
expanded_list[#expanded_list + 1] = {
expr = rhs,
title = custom_name and (custom_name .. " (RHS)") or rhs,
raw_expr = rhs
}
else
local diffExpr = (rhs == "0") and lhs or ("(" .. lhs .. ")-(" .. rhs .. ")")
expanded_list[#expanded_list + 1] = {
expr = diffExpr,
title = custom_name or raw_s,
raw_expr = raw_s
}
end
else
expanded_list[#expanded_list + 1] = {
expr = raw_s,
title = custom_name or raw_s,
raw_expr = raw_s
}
end
end
local series3D = {}
local allNorms = {}
local eval_env = { x = nil, z = nil }
for sIdx, item in ipairs(expanded_list) do
local calc_expr = normalizeComplexVarZ(item.expr)
local pre_expr, pre_scope = lib_calc._function_preprocessing(calc_expr, mathlib, numberConstructer, false)
local pf = lib_calc.infixToPostfix(pre_expr)
if type(pf) == "table" and pre_scope then pf.scope = pre_scope end
local function evalComplexAt(vx, vy)
local re_v, im_v, abs_v, hue_v = nil, nil, nil, 0
xpcall(function()
local z_val = zero_elem + vx + (i_value * vy)
eval_env.x = z_val
eval_env.z = z_val
local cval = lib_calc.calc_by_postfix(pf, eval_env, mathlib, numberConstructer, false)
if cval ~= nil and cval ~= " " then
local re = tonumber(mathlib.re(cval))
local im = tonumber((mathlib.im and mathlib.im(cval)) or 0)
if re and im and re == re and im == im then
if abs(re) < 1e12 and abs(im) < 1e12 then
local norm_val = sqrt(re * re + im * im)
if not mathlib.im and mathlib.abs then
norm_val = tonumber(mathlib.re(mathlib.abs(cval))) or norm_val
end
if norm_val == norm_val and norm_val < 1e12 then
local arg_rad = (mathlib.im and atan2(im, re)) or tonumber(mathlib.re(mathlib.arg(cval))) or 0
local h = (arg_rad * rad_to_deg) % 360
if h < 0 then h = h + 360 end
re_v, im_v, abs_v, hue_v = re, im, norm_val, h
end
end
end
end
end, noop)
return re_v, im_v, abs_v, hue_v
end
local dx = (x_end - x_start) / nx
local dy = (y_end - y_start) / ny
local grid = {}
for rx = 0, nx do
local vx = x_start + rx * dx
if abs(vx) < 1e-12 then vx = 0 end
local row = {}
for cy = 0, ny do
local vy = y_start + cy * dy
if abs(vy) < 1e-12 then vy = 0 end
local re_v, im_v, abs_v, hue_v = evalComplexAt(vx, vy)
if abs_v then
allNorms[#allNorms + 1] = abs_v
-- 儲存單位相量 (ux, uy) 供網格四角平均輻角計算
local inv = (abs_v > 1e-12) and (1.0 / abs_v) or 0
row[cy + 1] = {
vx, vy, abs_v,
re = re_v, im = im_v,
ux = re_v * inv, uy = im_v * inv,
hue = hue_v
}
else
row[cy + 1] = false
end
end
grid[rx + 1] = row
end
series3D[sIdx] = {
kind = "surface3d",
is_complex3d = true,
grid = grid,
nu = nx,
nv = ny,
u_min = x_start, u_max = x_end,
v_min = y_start, v_max = y_end,
eval_fn = function(x_q, y_q)
local re_v, im_v, abs_v, hue_v = evalComplexAt(x_q, y_q)
if abs_v then
return x_q, y_q, abs_v, re_v, im_v, hue_v
end
return nil
end,
title = tostring(item.title),
raw_expr = item.raw_expr,
parametric = false
}
end
-- 智慧型極點高度截斷 (Auto Pole-Clipping):避免 1/(z-z0) 在極點附近爆出極端大值壓扁整張曲面
local zMin = z_min_user or 0
local zMax = z_max_user
if not zMax then
if #allNorms > 0 then
sort(allNorms)
local rawMax = allNorms[#allNorms]
local p88 = allNorms[max(1, floor(#allNorms * 0.88))]
if p88 > 1e-6 and rawMax > p88 * 3.2 then
zMax = p88 * 2.2
else
zMax = rawMax
end
else
zMax = 1
end
end
if zMax <= zMin then zMax = zMin + 1 end
return {
is_3d = true,
is_complex3d = true,
series = series3D,
bounds = {
xMin = x_start, xMax = x_end,
yMin = y_start, yMax = y_end,
zMin = zMin, zMax = zMax,
dataZMin = zMin, dataZMax = zMax
}
}
end
-- 使用 Sutherland-Hodgman 演算法將 3D 多邊形對 3D 座標盒六個邊界面 [xMin..xMax, yMin..yMax, zMin..zMax] 進行精準截面裁切
local function clipPolygonBox(poly, xMin, xMax, yMin, yMax, zMin, zMax)
local function isInside(val, plane, isUpper)
if isUpper then
return val <= plane + 1e-7
else
return val >= plane - 1e-7
end
end
local function clipPlane(inPts, axisIdx, planeVal, isUpper)
local outPts = {}
local n = #inPts
if n == 0 then return outPts end
local prev = inPts[n]
local prevIn = isInside(prev[axisIdx], planeVal, isUpper)
for i = 1, n do
local curr = inPts[i]
local currIn = isInside(curr[axisIdx], planeVal, isUpper)
if currIn ~= prevIn then
local dv = curr[axisIdx] - prev[axisIdx]
local t = (abs(dv) > 1e-12) and ((planeVal - prev[axisIdx]) / dv) or 0.5
local nx = prev[1] + t * (curr[1] - prev[1])
local ny = prev[2] + t * (curr[2] - prev[2])
local nz = prev[3] + t * (curr[3] - prev[3])
if axisIdx == 1 then nx = planeVal
elseif axisIdx == 2 then ny = planeVal
else nz = planeVal end
outPts[#outPts + 1] = {
nx, ny, nz,
u = (prev.u and curr.u) and (prev.u + t * (curr.u - prev.u)) or nil,
v = (prev.v and curr.v) and (prev.v + t * (curr.v - prev.v)) or nil
}
end
if currIn then
outPts[#outPts + 1] = curr
end
prev, prevIn = curr, currIn
end
return outPts
end
local res = clipPlane(poly, 3, zMax, true)
if #res < 3 then return nil end
res = clipPlane(res, 3, zMin, false)
if #res < 3 then return nil end
res = clipPlane(res, 1, xMax, true)
if #res < 3 then return nil end
res = clipPlane(res, 1, xMin, false)
if #res < 3 then return nil end
res = clipPlane(res, 2, yMax, true)
if #res < 3 then return nil end
res = clipPlane(res, 2, yMin, false)
if #res < 3 then return nil end
return res
end
-- 將 3D 空間曲線線段裁切至 Z ∈ [zMin, zMax] 範圍內
local function clipSegmentZ(p0, p1, t0, t1, zMin, zMax)
local u0, u1 = 0.0, 1.0
local dz = p1[3] - p0[3]
local p_arr = { -dz, dz }
local q_arr = { p0[3] - zMin, zMax - p0[3] }
for k = 1, 2 do
local pk, qk = p_arr[k], q_arr[k]
if abs(pk) < 1e-12 then
if qk < 0 then return nil end
else
local r = qk / pk
if pk < 0 then
if r > u1 then return nil elseif r > u0 then u0 = r end
else
if r < u0 then return nil elseif r < u1 then u1 = r end
end
end
end
if u1 < u0 then return nil end
local dx, dy = p1[1] - p0[1], p1[2] - p0[2]
local dt = (t1 or 1) - (t0 or 0)
local cp0 = { p0[1] + u0 * dx, p0[2] + u0 * dy, p0[3] + u0 * dz }
local cp1 = { p0[1] + u1 * dx, p0[2] + u1 * dy, p0[3] + u1 * dz }
return cp0, cp1, (t0 or 0) + u0 * dt, (t0 or 0) + u1 * dt
end
-- 將所有 3D 曲面網格與 3D 空間曲線投影為可繪製圖元,執行六面體邊界裁切、計算雙面光照並依深度 Z-sorting 排序
function p.buildScenePrimitives(data3D, cam, palette, opts)
opts = opts or {}
local primitives = {}
local series_list = data3D.series or {}
local b = data3D.bounds
local xClipMin, xClipMax = b.xMin, b.xMax
local yClipMin, yClipMax = b.yMin, b.yMax
local zClipMin, zClipMax = b.zMin, b.zMax
local zDataMin = b.dataZMin or b.zMin
local zDataSpan = max(1e-9, min(b.dataZMax or b.zMax, zClipMax) - zDataMin)
-- 主光源向量 (來自相機左上方與世界正上方混合,確保立體感鮮明)
local lx = cam.viewDir[1] * 0.45 - cam.rightDir[1] * 0.35 + cam.upDir[1] * 0.75
local ly = cam.viewDir[2] * 0.45 - cam.rightDir[2] * 0.35 + cam.upDir[2] * 0.75
local lz = cam.viewDir[3] * 0.45 - cam.rightDir[3] * 0.35 + cam.upDir[3] * 0.75 + 0.4
local lLen = sqrt(lx * lx + ly * ly + lz * lz)
if lLen > 1e-9 then lx, ly, lz = lx / lLen, ly / lLen, lz / lLen end
local cmapName = opts.colormap
if cmapName == nil and #series_list == 1 and series_list[1].kind == "surface3d" and not opts.colors then
cmapName = "viridis"
elseif cmapName == "none" or cmapName == "no" or cmapName == "false" then
cmapName = nil
end
local showMesh = (opts.mesh == nil) and true or (opts.mesh ~= false)
local surfaceOpacity = tonumber(opts.surfaceOpacity or opts.opacity) or 0.92
local lineWidth = tonumber(opts.lineWidth) or 2.2
for sIdx, s in ipairs(series_list) do
local baseColor, baseAlpha = RenderUtils.parseColor(palette[(sIdx - 1) % #palette + 1])
local sAlpha = baseAlpha or surfaceOpacity
local function emitQuadPrimitive(p00, p10, p11, p01, u0, u1, v0, v1)
local qPoly = {
{ p00[1], p00[2], p00[3], u = u0, v = v0 },
{ p10[1], p10[2], p10[3], u = u1, v = v0 },
{ p11[1], p11[2], p11[3], u = u1, v = v1 },
{ p01[1], p01[2], p01[3], u = u0, v = v1 }
}
local clipped = clipPolygonBox(qPoly, xClipMin, xClipMax, yClipMin, yClipMax, zClipMin, zClipMax)
if not clipped then return end
local nx00, ny00, nz00 = cam.toNorm(p00[1], p00[2], p00[3])
local nx10, ny10, nz10 = cam.toNorm(p10[1], p10[2], p10[3])
local nx11, ny11, nz11 = cam.toNorm(p11[1], p11[2], p11[3])
local nx01, ny01, nz01 = cam.toNorm(p01[1], p01[2], p01[3])
local ax, ay, az = nx11 - nx00, ny11 - ny00, nz11 - nz00
local bx, by, bz = nx01 - nx10, ny01 - ny10, nz01 - nz10
local vnx = ay * bz - az * by
local vny = az * bx - ax * bz
local vnz = ax * by - ay * bx
local nLen = sqrt(vnx * vnx + vny * vny + vnz * vnz)
local diffuse = 0.5
if nLen > 1e-9 then
vnx, vny, vnz = vnx / nLen, vny / nLen, vnz / nLen
diffuse = abs(vnx * lx + vny * ly + vnz * lz)
end
local shadeFactor = 0.52 + 0.62 * diffuse
local sumZ, sumDepth = 0, 0
local ptsBuf = {}
local poly2D = {}
for k = 1, #clipped do
local cp = clipped[k]
sumZ = sumZ + cp[3]
local px, py, d = cam.projectPoint(cp[1], cp[2], cp[3])
sumDepth = sumDepth + d
ptsBuf[k] = format("%.2f,%.2f", px, py)
poly2D[k] = { x = px, y = py, vx = cp[1], vy = cp[2], vz = cp[3], u = cp.u, v = cp.v }
end
local avgZ = sumZ / #clipped
local avgDepth = sumDepth / #clipped
local fillColor, pureHueHex
if s.is_complex3d then
local avgUx = (p00.ux or 0) + (p10.ux or 0) + (p11.ux or 0) + (p01.ux or 0)
local avgUy = (p00.uy or 0) + (p10.uy or 0) + (p11.uy or 0) + (p01.uy or 0)
local hDeg
if avgUx * avgUx + avgUy * avgUy > 1e-6 then
hDeg = (math.atan2(avgUy, avgUx) * (180 / math.pi)) % 360
if hDeg < 0 then hDeg = hDeg + 360 end
else
hDeg = p00.hue or 0
end
pureHueHex = RenderUtils.hsbToHex(hDeg, 1.0, 1.0)
fillColor = RenderUtils.shadeColor(pureHueHex, shadeFactor)
elseif cmapName and #series_list == 1 then
local tNorm = (avgZ - zDataMin) / zDataSpan
fillColor = p.sampleColormap(cmapName, tNorm, shadeFactor)
else
fillColor = RenderUtils.shadeColor(baseColor, shadeFactor)
end
local strokeCol = showMesh and RenderUtils.shadeColor(fillColor, 0.76) or fillColor
primitives[#primitives + 1] = {
kind = "quad",
sIdx = sIdx,
depth = avgDepth,
pts = table.concat(ptsBuf, " "),
poly2D = poly2D,
pureHue = pureHueHex,
fill = fillColor,
stroke = strokeCol,
strokeW = showMesh and 0.45 or 0.2,
alpha = sAlpha
}
end
if s.kind == "surface3d" then
if s.quads then
-- 3D 隱函數等值面四邊形陣列
for _, q in ipairs(s.quads) do
emitQuadPrimitive(q[1], q[2], q[3], q[4], nil, nil, nil, nil)
end
else
local grid = s.grid
local nu, nv = s.nu, s.nv
local uMin, uSpan = s.u_min or 0, (s.u_max or 1) - (s.u_min or 0)
local vMin, vSpan = s.v_min or 0, (s.v_max or 1) - (s.v_min or 0)
for u = 1, nu do
local row0 = grid[u]
local row1 = grid[u + 1]
local u0 = uMin + ((u - 1) / nu) * uSpan
local u1 = uMin + (u / nu) * uSpan
for v = 1, nv do
local p00, p10, p11, p01 = row0[v], row1[v], row1[v + 1], row0[v + 1]
if p00 and p10 and p11 and p01 then
local v0 = vMin + ((v - 1) / nv) * vSpan
local v1 = vMin + (v / nv) * vSpan
emitQuadPrimitive(p00, p10, p11, p01, u0, u1, v0, v1)
end
end
end
end
elseif s.kind == "curve3d" then
local pts = s.points
local nPts = max(1, #pts - 1)
local tMin, tMax = s.t_min or 0, s.t_max or 1
local prevP, prevT = nil, nil
for k = 1, #pts do
local pt = pts[k]
local t_k = tMin + ((k - 1) / nPts) * (tMax - tMin)
if pt then
if prevP then
local cp0, cp1, ct0, ct1 = clipSegmentZ(prevP, pt, prevT, t_k, zClipMin, zClipMax)
if cp0 and cp1 then
local x1, y1, d1 = cam.projectPoint(cp0[1], cp0[2], cp0[3])
local x2, y2, d2 = cam.projectPoint(cp1[1], cp1[2], cp1[3])
primitives[#primitives + 1] = {
kind = "segment",
sIdx = sIdx,
depth = (d1 + d2) * 0.5,
x1 = x1, y1 = y1,
x2 = x2, y2 = y2,
p0 = cp0, p1 = cp1,
t0 = ct0, t1 = ct1,
stroke = baseColor,
strokeW = lineWidth,
alpha = sAlpha
}
end
end
prevP, prevT = pt, t_k
else
prevP = nil
end
end
end
end
-- 畫家演算法:由遠到近 (depth 由小到大) 排序
sort(primitives, function(a, b)
return a.depth < b.depth
end)
return primitives, cmapName
end
-- 2D 三角形重心座標碰撞測試:判斷 (px, py) 是否落在三角形內並回傳重心權重 (w0, w1, w2)
local function pointInTriBarycentric(px, py, x0, y0, x1, y1, x2, y2)
local denom = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2)
if abs(denom) < 1e-6 then return nil end
local w0 = ((y1 - y2) * (px - x2) + (x2 - x1) * (py - y2)) / denom
local w1 = ((y2 - y0) * (px - x2) + (x0 - x2) * (py - y2)) / denom
local w2 = 1.0 - w0 - w1
if w0 >= -1e-4 and w1 >= -1e-4 and w2 >= -1e-4 then
return w0, w1, w2
end
return nil
end
-- 對裁切後的凸多邊形 poly2D (3~6 頂點) 進行扇形三角剖分碰撞測試
local function hitTestClippedPolygon(gx, gy, poly2D)
if not poly2D or #poly2D < 3 then return nil end
local v0 = poly2D[1]
for k = 2, #poly2D - 1 do
local v1, v2 = poly2D[k], poly2D[k + 1]
local w0, w1, w2 = pointInTriBarycentric(gx, gy, v0.x, v0.y, v1.x, v1.y, v2.x, v2.y)
if w0 then
return w0, w1, w2, v0, v1, v2
end
end
return nil
end
-- 為 3D 曲面 (10x10 螢幕網格逆向深度遮擋剔除) 與 3D 曲線 (2D 投影弧長均勻分布 + 遮擋剔除) 產生精確重算的 Tooltips
function p.build3DTooltips(data3D, primitives, cam, palette, args, padL, padT, plotW, plotH)
local ttOverlays = {}
local series_list = data3D.series or {}
if #series_list == 0 or #primitives == 0 then return ttOverlays end
local b = data3D.bounds
-- 檢查螢幕點 (px, py) 是否被深度大於 refDepth 的前景多邊形遮擋
local function isOccludedByQuad(px, py, refDepth)
for idx = #primitives, 1, -1 do
local q = primitives[idx]
if q.depth <= refDepth + 0.02 then break end
if q.kind == "quad" and hitTestClippedPolygon(px, py, q.poly2D) then
return true
end
end
return false
end
-- 為每個 3D 系列預先產生唯一一次 <math> Strip Marker 供全部網格點共用
local seriesTitleHtml = {}
for sIdx, s in ipairs(series_list) do
local rawT = args["y" .. sIdx .. "Title"] or args[sIdx .. " name"] or s.title or "z"
seriesTitleHtml[sIdx] = TextRenderer.formatTooltipTitle(rawT)
end
local function append3DTooltipTag(px, py, colorHex, titleHtml, vx, vy, vz, isSolidDot, customCoordStr, customValStr)
local ttCls = "fg-tt"
if py - padT < 28 then ttCls = ttCls .. " fg-tt-bot" end
if px - padL < 55 then ttCls = ttCls .. " fg-tt-l"
elseif (padL + plotW) - px < 55 then ttCls = ttCls .. " fg-tt-r" end
local icoStyle = isSolidDot
and format("border-color:%s;background-color:%s;", colorHex, colorHex)
or format("border-color:%s;", colorHex)
local coordStr = customCoordStr or format("%s, %s", RenderUtils.formatTickValue(vx), RenderUtils.formatTickValue(vy))
local textBody
if customValStr == false then
textBody = format("%s (%s)", titleHtml, RenderUtils.esc(coordStr))
else
local zStr = customValStr or RenderUtils.formatTickValue(vz)
textBody = format("%s (%s): %s", titleHtml, RenderUtils.esc(coordStr), RenderUtils.esc(zStr))
end
ttOverlays[#ttOverlays + 1] = format(
'<span class="fg-hb" style="left:%.1fpx;top:%.1fpx;"><span class="fg-hb-ring" style="border-color:%s;"></span><span class="%s"><span class="fg-tt-ico" style="%s"></span>%s</span></span>',
px, py, colorHex, ttCls, icoStyle, textBody
)
end
-- 1. 針對 3D 曲面:使用 10x10 螢幕探測網格,從最前面 (最後繪製) 的多邊形逆向尋找未被遮擋點
local hasSurface = false
local sxMin, sxMax, syMin, syMax = math.huge, -math.huge, math.huge, -math.huge
for _, prim in ipairs(primitives) do
if prim.kind == "quad" and prim.poly2D then
hasSurface = true
for _, pt in ipairs(prim.poly2D) do
if pt.x < sxMin then sxMin = pt.x end
if pt.x > sxMax then sxMax = pt.x end
if pt.y < syMin then syMin = pt.y end
if pt.y > syMax then syMax = pt.y end
end
end
end
if hasSurface and sxMax > sxMin and syMax > syMin then
local gridN = 10
for r = 1, gridN do
local gy = syMin + ((r - 0.5) / gridN) * (syMax - syMin)
for c = 1, gridN do
local gx = sxMin + ((c - 0.5) / gridN) * (sxMax - sxMin)
for idx = #primitives, 1, -1 do
local q = primitives[idx]
if q.kind == "quad" then
local w0, w1, w2, pA, pB, pC = hitTestClippedPolygon(gx, gy, q.poly2D)
if w0 then
local s = series_list[q.sIdx]
local u_star = w0 * (pA.u or 0) + w1 * (pB.u or 0) + w2 * (pC.u or 0)
local v_star = w0 * (pA.v or 0) + w1 * (pB.v or 0) + w2 * (pC.v or 0)
local vx, vy, vz, c_re, c_im, c_hue
if s and s.eval_fn then
vx, vy, vz, c_re, c_im, c_hue = s.eval_fn(u_star, v_star)
end
if not vx or vz < b.zMin - 1e-4 or vz > b.zMax + 1e-4 then
vx = w0 * pA.vx + w1 * pB.vx + w2 * pC.vx
vy = w0 * pA.vy + w1 * pB.vy + w2 * pC.vy
vz = w0 * pA.vz + w1 * pB.vz + w2 * pC.vz
if s and s.project_fn then
vx, vy, vz = s.project_fn(vx, vy, vz)
end
end
local px, py = cam.projectPoint(vx, vy, vz)
if s and s.is_complex3d then
local zCoordStr = format("%s%s%si", RenderUtils.formatTickValue(vx), vy >= 0 and "+" or "", RenderUtils.formatTickValue(vy))
local wValStr
if c_re and c_im then
wValStr = format("%.2f%s%.2fi (|f|=%s)", c_re, c_im >= 0 and "+" or "", c_im, RenderUtils.formatTickValue(vz))
else
wValStr = format("|f|=%s", RenderUtils.formatTickValue(vz))
end
local dotCol = c_hue and RenderUtils.hsbToHex(c_hue, 1.0, 1.0) or q.pureHue or q.fill
append3DTooltipTag(px, py, dotCol, seriesTitleHtml[q.sIdx] or "<b>f(z)</b>", vx, vy, vz, true, zCoordStr, wValStr)
elseif s and (s.is_implicit3d or s.parametric) then
local xyzStr = format("%s, %s, %s", RenderUtils.formatTickValue(vx), RenderUtils.formatTickValue(vy), RenderUtils.formatTickValue(vz))
append3DTooltipTag(px, py, q.fill, seriesTitleHtml[q.sIdx] or "<b>z</b>", vx, vy, vz, true, xyzStr, false)
else
append3DTooltipTag(px, py, q.fill, seriesTitleHtml[q.sIdx] or "<b>z</b>", vx, vy, vz, true)
end
break
end
end
end
end
end
end
-- 2. 針對 3D 空間曲線:沿 2D 投影像素弧長均勻取樣並剔除被曲面遮擋的點
local curveCount = 0
for _, s in ipairs(series_list) do
if s.kind == "curve3d" then curveCount = curveCount + 1 end
end
if curveCount > 0 then
local quotaPerCurve = max(10, floor((hasSurface and 40 or 100) / curveCount))
for sIdx, s in ipairs(series_list) do
if s.kind == "curve3d" then
local segs = {}
local totalLen = 0
for _, prim in ipairs(primitives) do
if prim.kind == "segment" and prim.sIdx == sIdx then
local len = sqrt((prim.x2 - prim.x1) ^ 2 + (prim.y2 - prim.y1) ^ 2)
if len > 1e-4 then
totalLen = totalLen + len
segs[#segs + 1] = {
prim = prim, len = len,
cumStart = totalLen - len, cumEnd = totalLen
}
end
end
end
if #segs > 0 and totalLen > 0 then
local baseColor = RenderUtils.parseColor(palette[(sIdx - 1) % #palette + 1])
local titleHtml = seriesTitleHtml[sIdx] or "<b>z</b>"
local segPtr = 1
for m = 1, quotaPerCurve do
local targetS = ((m - 0.5) / quotaPerCurve) * totalLen
while segPtr < #segs and segs[segPtr].cumEnd < targetS do
segPtr = segPtr + 1
end
local sg = segs[segPtr]
local pr = sg.prim
local alpha = (sg.len > 1e-6) and max(0, min(1, (targetS - sg.cumStart) / sg.len)) or 0.5
local t_star = pr.t0 + alpha * (pr.t1 - pr.t0)
local vx, vy, vz
if s.eval_fn then
vx, vy, vz = s.eval_fn(t_star)
end
if not vx or vz < b.zMin - 1e-4 or vz > b.zMax + 1e-4 then
vx = pr.p0[1] + alpha * (pr.p1[1] - pr.p0[1])
vy = pr.p0[2] + alpha * (pr.p1[2] - pr.p0[2])
vz = pr.p0[3] + alpha * (pr.p1[3] - pr.p0[3])
end
local px, py, depth = cam.projectPoint(vx, vy, vz)
if not isOccludedByQuad(px, py, depth) then
local xyzStr = format("%s, %s, %s", RenderUtils.formatTickValue(vx), RenderUtils.formatTickValue(vy), RenderUtils.formatTickValue(vz))
append3DTooltipTag(px, py, baseColor, titleHtml, vx, vy, vz, true, xyzStr, false)
end
end
end
end
end
end
return ttOverlays
end
return p