跳转到内容

模組: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