
Edu Solid Geometry
- 333 installs
- 803 repo stars
- Updated June 29, 2026
- wy51ai/edulab
Generate standard 3D solid geometry topologies (vertices and edges) for edu visuals that pair with a precision geometry kernel.
About
Edu-solid-geometry is a Python-oriented agent skill from the Edulab line that gives solo edtech builders a reusable topology layer for 3D geometry lessons. Instead of recomputing which vertices belong to each solid from scratch, bodies.py exposes small factory functions—quad_pyramid, tri_pyramid, cuboid, prism—that return consistent spheres and edges structures meant to merge with geometry_kernel.py for numerically correct coordinates. That split keeps rendering pipelines predictable: the kernel handles math, the bodies module handles standard connectivity patterns exam writers and interactive tutors repeat. Use it when you are building visual explainers, worksheet generators, or agent-driven problem renderers where wrong edge wiring would confuse students. The readme is explicit that uncommon polyhedra can still be defined inline, so you are not locked to the canned library when a contest problem needs a custom skeleton.
- bodies.py topology library: declares which vertices connect on which edges for standard solids
- Designed to pair with geometry_kernel.py for exact coordinates while bodies supply graph structure
- Built-in constructors include quad_pyramid, tri_pyramid, cuboid, and prism with arbitrary base vertex order
- _edge helper standardizes edge dicts with optional metadata kwargs
- Rare solids can be hand-authored edge lists when not in the built-in catalog
Edu Solid Geometry by the numbers
- 333 all-time installs (skills.sh)
- +35 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #46 of 290 Python skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wy51ai/edulab --skill edu-solid-geometryAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 333 |
|---|---|
| repo stars | ★ 803 |
| Last updated | June 29, 2026 |
| Repository | wy51ai/edulab ↗ |
What it does
Generate standard 3D solid geometry topologies (vertices and edges) for edu visuals that pair with a precision geometry kernel.
Files
立体几何解题 → 交互网页
这个技能产出什么
一个可直接用浏览器打开的单页 HTML:左侧题面/答案/分步解析(公式用 MathJax), 右侧是题目对应的 3D 模型(Three.js,可旋转缩放,分步高亮关键元素并切换镜头)。 形态与 template/lesson.html 一致。
依赖(重要)
计算核心 lib/geometry_kernel.py 依赖 sympy。运行脚本前先确认有一个能 import sympy 的 python3:跑 python3 -c "import sympy"。
缺库时的处理(重要):若 import 报错(sympy 或后续用到的任何库都同理),先询问用户是否安装, 得到同意后再帮忙安装(python3 -m pip install <库名>),或换一个已装该库的解释器;不要未经询问直接装。 下文命令里的 python3 均指这个能跑通依赖的解释器。
工作流程
第 1 步:得到 problem spec(三入口归一)
把题目整理成结构化 spec(格式见 references/problem-schema.md):几何体类型与尺寸、 已知构造点/条件、所求类型与对象、语言。
- 文字题目:直接抽取。
- 图片:用视觉读图抽取,并把识别到的题目回显给用户确认(题面/几何体/尺寸/所求/语言)后再继续。
- 随机出题:选定几何体与题型,用 kernel 随机参数求解,答案不规整就重抽。
输出语言跟随提示词语言:英文提示 → 英文网页,中文 → 中文。spec 里记下 language。第 2 步:用 kernel 精确计算(不要心算)
按 references/conventions.md 的建系约定与解法配方,调用 lib/geometry_kernel.py: 得到精确坐标、关键向量、法向量、最终答案,以及各步骤要展示的中间量(均为 LaTeX 字符串)。 顶点的 three.js 坐标用 kernel.to_three(points, scale) 得到。
可先在命令行跑 kernel 验证答案,例如:
python3 lib/geometry_kernel.py # 内置样例自检第 3 步:组装 lesson data 并注入模板
📍 输出位置(重要):成品 HTML 一律写到用户当前工作目录(`Path.cwd()`),除非用户显式指定路径。
绝不要写进技能自身目录(skills/edu-solid-geometry/output/ 等)——那是技能内部的开发样例目录。临时构建脚本也放到 cwd 或临时目录(如 /tmp),用完可删。写一个临时构建脚本,导入 kernel、bodies、generate,拼出 lesson / steps / model 数据 (schema 见 references/problem-schema.md),再调用 generate.render_html(data, out) 注入模板产出 HTML。 out 用 cwd 下的绝对路径:
from pathlib import Path
out = Path.cwd() / "solution-<题目简述>.html" # 落在用户当前目录,而非技能目录
generate.render_html(data, out)steps[*].content里的所有数值直接引用 kernel 的计算结果,模型只负责组织讲解文字(按目标语言书写)。model.points用kernel.to_three(...)的结果;model.spheres/edges用lib/bodies.py的拓扑
(quad_pyramid / tri_pyramid / cuboid / cube / prism),罕见几何体可手写 edges。
- 每步配
highlight(该步可见元素的绝对集合)与cameraPos。 - 题面给出线段长度时:为对应棱加
measure元素(label用 LaTeX,如2\sqrt{2}),
并把它放进"建系/列已知条件"那步的 highlight,在 3D 图中点处标出长度(见 problem-schema)。
- 英文输出时填
lesson.ui英文文案并设lesson.language="en"。
可直接参考的范例:scripts/generate.py 里的 build_data()(正四棱锥·线面角)、 build_cube_data()(正方体·线面角)、build_box_volume_data()(长方体·体积)都是完整范本,照着改即可。
generate.py 可直接出已注册的题;不传路径时默认写到当前工作目录(cwd),也可显式给 cwd 下的文件名 (用技能目录里的 scripts/generate.py,输出落在 cwd):
python3 <技能目录>/scripts/generate.py cube ./cube.html
python3 <技能目录>/scripts/generate.py box ./box.html随机出题:generate.py random <seed> [输出.html],内部用 kernel.is_clean(...) 判答案规整、不过重抽:
python3 <技能目录>/scripts/generate.py random 7 ./random.html # 不给路径则默认 ./random.html(cwd)扩展随机题型时沿用"随机参数 → 求解 → is_clean 不过就重抽"。
第 4 步:自检(对应正确性方案)
- kernel 答案 == 答案卡
answerValue== 末步骤展示的最终值(generate.py 已有断言示例)。 - 3D 顶点坐标来自
kernel.to_three(与解题同源)。 - 起本地静态服务(服务输出文件所在目录,即 cwd)用预览检查:无控制台报错、公式渲染正常、分步高亮/镜头符合预期。
(技能仓库内开发时可用 .claude/launch.json 的 geom-preview;在别处运行就对 cwd 起一个临时静态服务。)
⚠️ 必须关闭你开过的端口/服务:预览检查一结束就立即停掉本地服务,绝不留下占用端口的进程。
- 用 preview 工具开的:检查完马上 preview_stop(传对应 serverId)。- 直接起的http.server:用完kill掉,或核对lsof -nP -iTCP:<port> -sTCP:LISTEN确认已释放。
- 交付前确认端口已释放,再告诉用户结果。开了不关 = 未完成自检。
第 5 步:交付
成品写在用户当前工作目录(cwd),命名形如 solution-<题目简述>.html,把(cwd 下的)路径告诉用户,可直接浏览器打开。 交付前确认:(1) 成品在 cwd、不在技能目录;(2) 没有遗留任何由本次预览开启的本地服务/端口。
扩展
- 加题型:在
geometry_kernel.py加求解函数(见 conventions 配方表),在generate.py加一个build_*。 - 加几何体:在
geometry_kernel.py加坐标构建函数,在bodies.py加棱拓扑。
目录
template/lesson.html— 数据驱动模板(通用 3D 渲染器 + 数据岛__LESSON_DATA__)lib/geometry_kernel.py— sympy 精确计算核心lib/bodies.py— 几何体棱拓扑库scripts/generate.py— 注入模板 + 范例构建函数references/problem-schema.md— 数据格式references/conventions.md— 建系约定、解法配方、自检
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
bodies.py — 几何体"拓扑"库(哪些顶点、哪些棱)。
与 geometry_kernel.py 配合:kernel 负责精确坐标,bodies 负责标准棱连接,
两者合成 3D 渲染所需的 model(spheres + edges)。常见几何体在此内置;
罕见几何体可在具体题目里手写 edges。
"""
def _edge(a, b, **kw):
e = {"a": a, "b": b}
e.update(kw)
return e
def quad_pyramid(apex="P", base=("A", "B", "C", "D")):
"""四棱锥:底面四边形 + 顶点到各底点。返回 spheres 与 edges。"""
a, b, c, d = base
edges = [
_edge(a, b), _edge(b, c), _edge(c, d), _edge(d, a),
_edge(apex, a), _edge(apex, b), _edge(apex, c), _edge(apex, d),
]
return {"spheres": [apex, a, b, c, d], "edges": edges}
def tri_pyramid(apex="P", base=("A", "B", "C")):
"""三棱锥(四面体)。"""
a, b, c = base
edges = [
_edge(a, b), _edge(b, c), _edge(c, a),
_edge(apex, a), _edge(apex, b), _edge(apex, c),
]
return {"spheres": [apex, a, b, c], "edges": edges}
def cuboid(bottom=("A", "B", "C", "D"), top=("A1", "B1", "C1", "D1")):
"""长方体 / 正方体:底面四边形、顶面四边形、四条竖棱。"""
a, b, c, d = bottom
a1, b1, c1, d1 = top
edges = [
_edge(a, b), _edge(b, c), _edge(c, d), _edge(d, a), # 底面
_edge(a1, b1), _edge(b1, c1), _edge(c1, d1), _edge(d1, a1), # 顶面
_edge(a, a1), _edge(b, b1), _edge(c, c1), _edge(d, d1), # 竖棱
]
return {"spheres": [a, b, c, d, a1, b1, c1, d1], "edges": edges}
def prism(bottom=("A", "B", "C"), top=("A1", "B1", "C1")):
"""棱柱:上下同形多边形 + 竖棱(顶点数任意,按顺序一一对应)。"""
n = len(bottom)
edges = []
for i in range(n):
edges.append(_edge(bottom[i], bottom[(i + 1) % n]))
edges.append(_edge(top[i], top[(i + 1) % n]))
edges.append(_edge(bottom[i], top[i]))
return {"spheres": list(bottom) + list(top), "edges": edges}
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
geometry_kernel.py — 立体几何确定性计算核心(基于 sympy 精确符号运算)。
设计目标(对应方案 A):坐标、向量、最终答案全部由本模块精确算出,
根式自动化简,杜绝心算误差。同一套坐标既喂给解题文案,也喂给 3D 渲染,
保证"图、解、答"严格一致。
依赖: sympy(pip install sympy)。
坐标约定(数学坐标,z 轴向上):
- 题面/公式里展示的就是这套数学坐标。
- 3D 渲染坐标采用 three.js 约定(y 轴向上):three = (x, z, y) * scale。
"""
import sympy as sp
sqrt = sp.sqrt
# ===================== 基础工具 =====================
def V(*comps):
"""构造列向量(sympy.Matrix)。"""
if len(comps) == 1 and isinstance(comps[0], (list, tuple)):
comps = comps[0]
return sp.Matrix([sp.sympify(c) for c in comps])
def midpoint(a, b):
return (a + b) / 2
def normal_from_points(p, q, r):
"""由平面上三点求法向量(叉积),返回未化简向量。"""
return (q - p).cross(r - p)
def simplify_vec(v):
"""把向量按公因子约简到最简整系数方向(仅用于'简化取 n=...'的展示)。"""
v = sp.Matrix([sp.simplify(c) for c in v])
nonzero = [c for c in v if c != 0]
if not nonzero:
return v
g = nonzero[0]
for c in nonzero[1:]:
g = sp.gcd(g, c)
if g != 0:
cand = sp.simplify(v / g)
# 若约简后仍是整数向量则采用
if all(c.is_rational for c in cand):
return cand
return v
def line_plane_angle_sin(line_dir, normal):
"""线面角正弦:sinθ = |v·n| / (|v||n|),精确化简。"""
v = line_dir
n = normal
s = sp.Abs(v.dot(n)) / (v.norm() * n.norm())
return sp.simplify(s)
def line_line_angle_cos(d1, d2):
"""异面直线夹角余弦:cosθ = |d1·d2| / (|d1||d2|)。"""
return sp.simplify(sp.Abs(d1.dot(d2)) / (d1.norm() * d2.norm()))
def point_plane_distance(point, plane_point, normal):
"""点到平面距离:|(P - P0)·n| / |n|。"""
return sp.simplify(sp.Abs((point - plane_point).dot(normal)) / normal.norm())
def dihedral_cos(A, B, C, D):
"""二面角 C-AB-D 的余弦:在两个半平面内各取一条垂直于棱 AB 的向量再求夹角。
AB 为棱,C 在一个面内,D 在另一个面内。返回带符号的精确余弦
(正=锐二面角,负=钝二面角)。
"""
u = B - A
def perp(P):
w = P - A
return w - (w.dot(u) / u.dot(u)) * u
v1, v2 = perp(C), perp(D)
return sp.simplify(v1.dot(v2) / (v1.norm() * v2.norm()))
def dihedral_cos_from_normals(n1, n2):
"""由两半平面法向量求二面角余弦(符号依赖法向量取向,通常配合几何判断锐钝)。"""
return sp.simplify(n1.dot(n2) / (n1.norm() * n2.norm()))
# ===================== 体积 =====================
def volume_box(lx, ly, lz):
return sp.simplify(sp.sympify(lx) * sp.sympify(ly) * sp.sympify(lz))
def volume_prism(base_area, height):
return sp.simplify(sp.sympify(base_area) * sp.sympify(height))
def volume_pyramid(base_area, height):
return sp.simplify(sp.Rational(1, 3) * sp.sympify(base_area) * sp.sympify(height))
def volume_tetra(A, B, C, D):
"""四面体体积 = |(AB × AC)· AD| / 6。"""
return sp.simplify(sp.Abs((B - A).cross(C - A).dot(D - A)) / 6)
# ===================== LaTeX 输出 =====================
def tex(expr):
return sp.latex(sp.simplify(expr))
def is_clean(expr, max_ops=7, max_radicand=60):
"""判断答案是否“规整”:化简后复杂度低、至多含小整数根号、无嵌套根式。
用于随机出题:参数随机求解后,答案不规整就重抽。
"""
e = sp.radsimp(sp.nsimplify(sp.simplify(expr)))
if e.has(sp.zoo, sp.nan, sp.oo) or e.free_symbols:
return False
if sp.count_ops(e) > max_ops:
return False
for p in e.atoms(sp.Pow):
if p.exp == sp.Rational(1, 2):
rad = p.base
if not (rad.is_Integer and 0 <= int(rad) <= max_radicand):
return False
if rad.atoms(sp.Pow): # 嵌套根式
return False
return True
def tex_vec(v):
return "(" + ", ".join(sp.latex(sp.simplify(c)) for c in v) + ")"
# ===================== 几何体构建库(数学坐标) =====================
def regular_quad_pyramid(base_edge, height):
"""正四棱锥 P-ABCD:底面中心 O 为原点,对角线 AC 在 x 轴、BD 在 y 轴,顶点 P 在 z 轴。
返回 {name: sympy 列向量}(数学坐标)。
"""
a = sp.sympify(base_edge)
h = sp.sympify(height)
d = sp.simplify(a / sqrt(2)) # 半对角线 = a√2/2
return {
"O": V(0, 0, 0),
"A": V(d, 0, 0),
"C": V(-d, 0, 0),
"B": V(0, d, 0),
"D": V(0, -d, 0),
"P": V(0, 0, h),
}
def cuboid(lx, ly, lz):
"""长方体 ABCD-A1B1C1D1:A 在原点,AB 沿 x,AD 沿 y,AA1 沿 z。"""
lx, ly, lz = sp.sympify(lx), sp.sympify(ly), sp.sympify(lz)
return {
"A": V(0, 0, 0), "B": V(lx, 0, 0), "C": V(lx, ly, 0), "D": V(0, ly, 0),
"A1": V(0, 0, lz), "B1": V(lx, 0, lz), "C1": V(lx, ly, lz), "D1": V(0, ly, lz),
}
def cube(edge):
"""正方体(长方体的特例)。"""
return cuboid(edge, edge, edge)
def regular_tetrahedron(edge=2 * sqrt(2)):
"""正四面体 ABCD(默认棱长 2√2 时坐标为整数)。返回 {name: 数学向量}。"""
base = {
"A": V(1, 1, 1),
"B": V(1, -1, -1),
"C": V(-1, 1, -1),
"D": V(-1, -1, 1),
}
k = sp.simplify(sp.sympify(edge) / (2 * sqrt(2))) # 缩放到目标棱长
return {name: sp.simplify(k * v) for name, v in base.items()}
# ===================== 数学坐标 -> three.js 坐标 =====================
def to_three(points, scale=1.5):
"""{name: 数学向量} -> {name: [x, y, z] 浮点(three.js: y 向上)}。"""
s = sp.Float(scale)
out = {}
for name, p in points.items():
mx, my, mz = p[0], p[1], p[2]
three = (mx * s, mz * s, my * s) # three = (x, z, y) * scale
out[name] = [float(c) for c in three]
return out
# ===================== 具体题目求解(线面角样例) =====================
def solve_pyramid_line_plane_angle(base_edge=2, height=1, scale=1.5):
"""正四棱锥 P-ABCD,E 为 PC 中点,求直线 BE 与平面 PAC 所成角的正弦值。
返回组装网页所需的全部数据:精确答案、数学坐标、three 坐标、各步骤中间量(LaTeX)。
"""
pts = regular_quad_pyramid(base_edge, height)
pts["E"] = midpoint(pts["P"], pts["C"])
BE = pts["E"] - pts["B"]
n = normal_from_points(pts["P"], pts["A"], pts["C"]) # 平面 PAC 法向量
n_simpl = simplify_vec(n)
sin_theta = line_plane_angle_sin(BE, n)
dot = BE.dot(n_simpl)
norm_BE = sp.sqrt(sum(c**2 for c in BE))
return {
"answer_latex": tex(sin_theta),
"math_points": {k: tex_vec(v) for k, v in pts.items()},
"three_points": to_three(pts, scale=scale),
"vals": {
"E": tex_vec(pts["E"]),
"BE": tex_vec(BE),
"n": tex_vec(n),
"n_simpl": tex_vec(n_simpl),
"dot": tex(dot),
"norm_BE": tex(sp.simplify(norm_BE)),
"sin": tex(sin_theta),
},
"_exact": {"sin_theta": sin_theta}, # 供自检比对
}
def solve_cube_line_plane_angle(edge=1, scale=2):
"""正方体 ABCD-A1B1C1D1(棱长 a),求直线 A1C 与底面 ABCD 所成角的正弦值。"""
pts = cube(edge)
line = pts["C"] - pts["A1"] # 方向向量 A1C
n = normal_from_points(pts["A"], pts["B"], pts["D"]) # 底面 ABCD 法向量
n_simpl = simplify_vec(n)
sin_theta = line_plane_angle_sin(line, n)
dot = line.dot(n_simpl)
norm_line = sp.sqrt(sum(c**2 for c in line))
return {
"answer_latex": tex(sin_theta),
"math_points": {k: tex_vec(v) for k, v in pts.items()},
"three_points": to_three(pts, scale=scale),
"vals": {
"A1C": tex_vec(line),
"n": tex_vec(n),
"n_simpl": tex_vec(n_simpl),
"dot": tex(dot),
"norm_line": tex(sp.simplify(norm_line)),
"sin": tex(sin_theta),
},
"_exact": {"sin_theta": sin_theta},
}
if __name__ == "__main__":
sol = solve_pyramid_line_plane_angle()
expected = 2 * sqrt(22) / 11
got = sol["_exact"]["sin_theta"]
ok = sp.simplify(got - expected) == 0
print("答案(LaTeX):", sol["answer_latex"])
print("E:", sol["vals"]["E"])
print("BE:", sol["vals"]["BE"])
print("法向量 n:", sol["vals"]["n"], "-> 简化", sol["vals"]["n_simpl"])
print("|BE|:", sol["vals"]["norm_BE"])
print("three 坐标 E:", sol["three_points"]["E"])
print("复现 2√22/11 :", "通过" if ok else "失败")
assert ok, "线面角答案与期望 2√22/11 不一致"
sol2 = solve_cube_line_plane_angle()
exp2 = sqrt(3) / 3
ok2 = sp.simplify(sol2["_exact"]["sin_theta"] - exp2) == 0
print("正方体 A1C-底面:", sol2["answer_latex"], "复现 √3/3 :", "通过" if ok2 else "失败")
assert ok2, "正方体线面角答案与期望 √3/3 不一致"
print("\n--- 四类求解器自检 ---")
# 异面直线夹角:正方体中 A1C 与 AB(cos = √3/3)
cb = cube(1)
cos_ll = line_line_angle_cos(cb["C"] - cb["A1"], cb["B"] - cb["A"])
ok_ll = sp.simplify(cos_ll - sqrt(3) / 3) == 0
print("异面直线 A1C·AB cos =", tex(cos_ll), "(期望 √3/3)", "通过" if ok_ll else "失败")
assert ok_ll
# 点到平面距离:正方体 A1 到底面 ABCD(= 1)
n_base = normal_from_points(cb["A"], cb["B"], cb["D"])
dist = point_plane_distance(cb["A1"], cb["A"], n_base)
ok_d = sp.simplify(dist - 1) == 0
print("A1 到底面 ABCD 距离 =", tex(dist), "(期望 1)", "通过" if ok_d else "失败")
assert ok_d
# 二面角:正四面体 C-AB-D(cos = 1/3)
tet = regular_tetrahedron()
cos_dih = dihedral_cos(tet["A"], tet["B"], tet["C"], tet["D"])
ok_dih = sp.simplify(cos_dih - sp.Rational(1, 3)) == 0
print("正四面体二面角 cos =", tex(cos_dih), "(期望 1/3)", "通过" if ok_dih else "失败")
assert ok_dih
# 体积:正四面体棱长 2√2 -> 体积 8/3;正三棱锥/盒子核对
vol_tet = volume_tetra(tet["A"], tet["B"], tet["C"], tet["D"])
ok_v = sp.simplify(vol_tet - sp.Rational(8, 3)) == 0
print("正四面体(棱2√2) 体积 =", tex(vol_tet), "(期望 8/3)", "通过" if ok_v else "失败")
assert ok_v
assert volume_box(2, 3, 4) == 24 and volume_pyramid(4, 3) == 4
print("体积 box(2,3,4)=24, pyramid(4,3)=4 通过")
print("\n全部自检通过 ✅")
约定与解法配方(conventions)
1. 坐标系与映射
数学坐标(z 轴向上) 是解题与公式展示用的坐标;three.js 坐标(y 轴向上) 是渲染用的坐标。
映射(geometry_kernel.to_three):three = (x, z, y) * scale。scale 只影响观感,不影响解题数值。
各几何体的标准建系(见 geometry_kernel.py):
regular_quad_pyramid(base_edge, height)— 底面中心 $O$ 为原点,对角线 $AC$ 在 $x$ 轴、$BD$ 在 $y$ 轴,顶点 $P$ 在 $z$ 轴。半对角线 $d = a/\sqrt2$。cuboid(lx, ly, lz)/cube(edge)— 顶点 $A$ 为原点,$AB$ 沿 $x$、$AD$ 沿 $y$、$AA_1$ 沿 $z$。- 新几何体:在 kernel 加一个返回
{name: V(x,y,z)}的函数;在bodies.py加对应的棱拓扑。
2. 解法配方(query.type → kernel 函数)
所有所求都走"建系 + 向量法",数值由 kernel 精确算出,不要心算。
| query.type | 公式 | kernel 函数 |
|---|---|---|
line_plane_angle | $\sin\theta = \dfrac{ | \vec v\cdot\vec n |
line_line_angle | $\cos\theta = \dfrac{ | \vec{d_1}\cdot\vec{d_2} |
dihedral | 两半平面法向量夹角余弦(注意正负号 / 钝锐) | 用 normal_from_points + 余弦公式 |
point_plane_distance | $d = \dfrac{ | (P-P_0)\cdot\vec n |
volume | 按体型公式 | (按需在 kernel 添加) |
辅助:midpoint(a,b)、normal_from_points(p,q,r)(叉积)、simplify_vec(v)(约简到最简整系数方向,用于"简化取 n=…"展示)、tex(expr) / tex_vec(v)(LaTeX 输出)。
3. 步骤与镜头
- 典型 4 步:建系 → 求关键向量 → 求法向量/方向 → 代入公式得答案。
- 每步
highlight给出该步应可见的元素(绝对集合);常见节奏:建系亮坐标轴 → 逐步亮出关键线、平面、法向量。 - 每步
cameraPos给一个能看清当前重点的视角(three 坐标);target通常取几何体中心。
4. 渲染元素建议
- 关键线(所求直线)用
emphasis色并depthTest:false(始终可见)。 - 辅助线(对角线、投影)用
aux色 +dashed。 - 所求平面用
plane(半透明)。法向量用arrow+normal色。 - 坐标轴用
axes,一般只在建系那步显示。
5. 正确性自检(必须)
- kernel 算出的答案,必须与"答案卡
answerValue"和"末步骤展示的最终值"三者一致。 - 3D 顶点坐标必须来自
kernel.to_three(与解题同源),不要另行手填坐标。 - 随机题:与 kernel 生成时的标准答案比对。
- 生成后建议起本地服务用预览检查:无控制台报错、公式渲染正常、分步高亮与镜头符合预期。
数据格式参考(problem-schema)
三个入口(文字 / 图片 / 随机)最终都归一成同一份 problem spec,再由计算与渲染共用。
1. problem spec(结构化题目,入口归一的中间产物)
{
"language": "zh-CN", // 跟随提示词语言:zh-CN / en / ...
"body": "regular_quad_pyramid", // 几何体类型,见 conventions.md
"dims": { "base_edge": 2, "height": 1 }, // 几何体尺寸参数
"givens": [ // 额外构造点 / 条件
{ "name": "E", "kind": "midpoint", "of": ["P", "C"] }
],
"query": { // 所求
"type": "line_plane_angle", // 见 conventions.md 的解法配方
"line": ["B", "E"],
"plane": ["P", "A", "C"]
}
}图片入口:先把识别到的 spec 回显给用户确认(题面、几何体、尺寸、所求、语言)再继续。
随机入口:由 kernel 反向生成 spec(随机参数→求解→答案不规整则重抽),自带标准答案。
2. lesson data(注入模板 __LESSON_DATA__ 的最终数据)
模板 template/lesson.html 读取一个 JSON 对象,含三部分:lesson / steps / model。
{
"lesson": {
"language": "zh-CN",
"meta": "交互解题 · 线面角", // 顶部小标签
"title": "……题面……",
"answerLabel": "……答案的文字说明……",
"answerValue": "$\\frac{2\\sqrt{22}}{11}$", // LaTeX,含 $…$
"ui": { /* 可选:覆盖界面文案,见下文多语言 */ }
},
"steps": [
{
"title": "步骤标题",
"content": "<p>HTML 段落,行内公式 $…$,独立公式 $$…$$</p>",
"highlight": ["Line_BE", "Plane_PAC"], // 该步要“可见”的可切换元素(绝对集合)
"cameraPos": { "x": 4, "y": 4.5, "z": 4 } // 该步镜头位置(three 坐标)
}
],
"model": {
"points": { "P": [0, 1.5, 0], "A": [2.12, 0, 0] }, // three 坐标(y 向上),来自 kernel.to_three
"spheres": ["P", "A", "B", "C", "D", "E"], // 画小球+标签的点
"edges": [ // 始终可见的骨架棱
{ "a": "A", "b": "B" },
{ "a": "D", "b": "A", "dashed": true }, // 虚线
{ "a": "B", "b": "D", "color": "aux", "dashed": true, "name": "Line_BD" } // 命名后可被 highlight
],
"elements": { // 可切换命名元素,默认隐藏
"Line_BE": { "type": "line", "a": "B", "b": "E", "color": "emphasis", "depthTest": false },
"Plane_PAC":{ "type": "plane", "pts": ["P", "A", "C"] }, // 3 或 4 个点
"Normal_Vector": { "type": "arrow", "origin": "O", "dir": [0,0,1], "length": 1.5, "color": "normal" },
"Axis": { "type": "axes", "size": 3 }
},
"target": [0, 0.45, 0], // OrbitControls 注视点(three 坐标)
"initialCamera": [5, 4, 5] // 初始相机位置
}
}元素类型(model.elements[*].type)
line— 需要a、b(点名);color(语义色名);dashed;depthTest:false表示永远画在最前。plane— 需要pts(3 或 4 个点名)。arrow— 需要origin(点名或坐标)、dir(three 方向向量)、length、color。axes— 需要size。measure— 线段长度标注:在a、b两点中点处朝几何体外侧偏移贴一个 MathJax 长度标签。a、b:线段端点(点名)。label:长度的 LaTeX(不带$),如"2"、"2\\sqrt{2}"、"\\frac{\\sqrt3}{2}"。offset:可选,朝外偏移量(默认 0.24),避免压住棱。- 何时用:题面给出了线段长度(已知条件)就为对应棱加一个
measure,并把它的 key 放进"建系/列已知条件"那步的highlight。和其它元素一样受分步highlight控制显隐。 - 总开关:只要存在任一
measure,3D 画布左上会自动出现"长度标注:开/关"按钮,可一键显示/隐藏全部长度标签(叠加在分步 highlight 之上)。无需额外数据。英文输出时在lesson.ui设measureToggleOn/measureToggleOff文案。
颜色语义名(COLORS)
frame(骨架灰) · aux(辅助浅灰) · emphasis(强调洋红) · normal(法向量红) · plane(平面蓝) · point(顶点深蓝)
highlight 规则
每步的 highlight 是该步应可见的可切换元素的完整列表(绝对集合,不是增量)。骨架棱、顶点小球始终可见,不必列入。
动点拖拽 + 实时数值(model.draggable,可选)
让一个动点沿约束线段拖动,联动依赖点与图元,并实时显示真实几何量(在数学坐标下计算)。需要同时提供 model.scale 与 model.mathPoints(各点数学坐标,数值数组)。
"model": {
"scale": 1.4, // 与 kernel.to_three 用的 scale 一致
"mathPoints": { "A1": [2,0,2], "C1": [0,2,2], "P": [0.5,1.5,2], "C": [0,2,0], "B1": [0,0,2], "A": [2,0,0] },
"draggable": {
"point": "P", // 被拖动的点(会画成更大的强调色球)
"along": ["A1", "C1"], // 约束线段端点(点名)
"t": 0.75, // 题目设定位置的参数 t∈[0,1](如 A1P=3PC1 -> 0.75)
"standardLabel": "标准位 A₁P=3PC₁",
"dependent": [ { "name": "D", "kind": "midpoint", "of": ["P", "C"] } ], // 随动点重算的依赖点
"readouts": [ // 实时数值(数学坐标下计算)
{ "label": "三棱锥 B₁-APC 体积", "type": "volume_tetra", "pts": ["B1","A","P","C"] },
{ "label": "A₁P 长度", "type": "length", "pts": ["A1","P"] }
]
}
}- readout
type支持:volume_tetra(4 点)、length(2 点)、line_plane_angle_sin(line:2 点,plane:3 点)。 - 拖到
t附近会显示"标准位 ✓"。题面步骤里的精确符号解仍对应该标准位。
多语言(lesson.ui,可选)
模板内置中文 defaultUI 兜底。输出英文时,把界面文案放入 lesson.ui(键见 template/lesson.html 的 defaultUI),例如 previous/next/finish/stepTemplate/sceneLabel/...,并把 lesson.language 设为 en。steps.content 与 title 由模型按目标语言书写。answerValue 等 LaTeX 与语言无关。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
generate.py — 把结构化课程数据注入 template/lesson.html,产出单页 HTML。
数据全部由 lib/geometry_kernel.py 的确定性计算驱动(方案 A):
坐标、向量、最终答案均为 sympy 精确计算结果,3D 坐标与解题数值同源、严格一致。
依赖: sympy。用一个能 import sympy 的 python3 运行(若缺: python3 -m pip install sympy):
python3 scripts/generate.py [输出路径.html]
"""
import json
import sys
from pathlib import Path
SKILL_DIR = Path(__file__).resolve().parent.parent
TEMPLATE = SKILL_DIR / "template" / "lesson.html"
PLACEHOLDER = "__LESSON_DATA__"
sys.path.insert(0, str(SKILL_DIR / "lib"))
import geometry_kernel as gk # noqa: E402
import bodies # noqa: E402
def _centroid(three_points):
names = list(three_points)
n = len(names)
return [sum(three_points[k][i] for k in names) / n for i in range(3)]
def render_html(data: dict, out_path: Path) -> Path:
"""把数据以 JSON 形式注入模板占位符,写出 html。"""
template = TEMPLATE.read_text(encoding="utf-8")
if PLACEHOLDER not in template:
raise RuntimeError(f"模板中未找到占位符 {PLACEHOLDER}")
payload = json.dumps(data, ensure_ascii=False)
html = template.replace(PLACEHOLDER, payload)
out_path.write_text(html, encoding="utf-8")
return out_path
def build_cube_data() -> dict:
"""正方体 ABCD-A1B1C1D1(棱长 1),求直线 A1C 与底面 ABCD 所成角的正弦值。
几何体拓扑取自 bodies.cuboid,顶点坐标与答案取自 geometry_kernel(同源)。
"""
sol = gk.solve_cube_line_plane_angle(edge=1, scale=2)
mp = sol["math_points"]
v = sol["vals"]
ans = sol["answer_latex"]
tp = sol["three_points"]
topo = bodies.cuboid() # spheres + 12 条棱
center = _centroid(tp)
model = {
"target": center,
"initialCamera": [6, 5, 7],
"points": tp,
"spheres": topo["spheres"],
"edges": topo["edges"],
"elements": {
"Line_A1C": {"type": "line", "a": "A1", "b": "C", "color": "emphasis", "depthTest": False},
"Plane_ABCD": {"type": "plane", "pts": ["A", "B", "C", "D"]},
"Normal_Vector": {"type": "arrow", "origin": "A", "dir": [0, 1, 0], "length": 1.6, "color": "normal"},
"Axis": {"type": "axes", "size": 2.6},
},
}
lesson = {
"language": "zh-CN",
"meta": "交互解题 · 线面角",
"title": "正方体ABCD-A₁B₁C₁D₁中,棱长为1,求直线A₁C与底面ABCD所成角的正弦值",
"answerLabel": "直线 A₁C 与底面 ABCD 所成角的正弦值",
"answerValue": f"${ans}$",
}
steps = [
{
"title": "建立空间直角坐标系",
"content": (
r"<p>以顶点 $A$ 为原点,$AB$、$AD$、$AA_1$ 分别为 $x$、$y$、$z$ 轴建立坐标系。</p>"
r"<p>因为棱长为 $1$,关键点坐标为:</p>"
r"$$A" + mp["A"] + r", C" + mp["C"] + r", A_1" + mp["A1"] + r"$$"
),
"highlight": ["Axis"],
"cameraPos": {"x": 6, "y": 5, "z": 7},
},
{
"title": "求直线 A₁C 的方向向量",
"content": (
r"<p>直线 $A_1C$ 的方向向量为:</p>"
r"$$\vec{A_1C} = C - A_1 = " + v["A1C"] + r"$$"
),
"highlight": ["Line_A1C"],
"cameraPos": {"x": 5, "y": 4, "z": 8},
},
{
"title": "确定底面 ABCD 的法向量",
"content": (
r"<p>底面 $ABCD$ 在平面 $z = 0$ 上,其法向量竖直向上:</p>"
r"$$\vec{n} = " + v["n_simpl"] + r"$$"
),
"highlight": ["Line_A1C", "Plane_ABCD", "Normal_Vector"],
"cameraPos": {"x": 4, "y": 6, "z": 5},
},
{
"title": "利用向量公式求解",
"content": (
r"<p>设直线 $A_1C$ 与底面所成角为 $\theta$。</p>"
r"<p>线面角公式:$\sin\theta = \dfrac{|\vec{A_1C} \cdot \vec{n}|}{|\vec{A_1C}|\,|\vec{n}|}$</p>"
r"<p>计算数量积与模长:</p>"
r"$$\vec{A_1C} \cdot \vec{n} = " + v["dot"] + r", \quad |\vec{A_1C}| = " + v["norm_line"] + r"$$"
r"<p>代入公式:</p>"
r"$$\sin\theta = " + v["sin"] + r"$$"
r"<p>所以,直线 $A_1C$ 与底面 $ABCD$ 所成角的正弦值为 $" + ans + r"$。</p>"
),
"highlight": ["Line_A1C", "Plane_ABCD", "Normal_Vector"],
"cameraPos": {"x": 5, "y": 5, "z": 6},
},
]
return {"lesson": lesson, "steps": steps, "model": model, "_answer": ans}
def build_data(base_edge=2, height=1) -> dict:
"""正四棱锥 P-ABCD(E 为 PC 中点)线面角;底边、高可参数化(随机出题用)。
数值全部来自 geometry_kernel,文字步骤只负责把这些数值组织成讲解。
"""
sol = gk.solve_pyramid_line_plane_angle(base_edge=base_edge, height=height, scale=1.5)
mp = sol["math_points"] # 数学坐标的 LaTeX,例如 "(\\sqrt{2}, 0, 0)"
v = sol["vals"] # 各步骤中间量 LaTeX
ans = sol["answer_latex"]
diag_tex = gk.tex(gk.sp.sympify(base_edge) * gk.sqrt(2))
half_tex = gk.tex(gk.sp.sympify(base_edge) * gk.sqrt(2) / 2)
# ---- 3D 模型:顶点坐标取自 kernel(与解题同源);拓扑/高亮在此声明 ----
model = {
"target": [0, 0.45, 0],
"initialCamera": [5, 4, 5],
"points": sol["three_points"],
"spheres": ["O", "P", "A", "B", "C", "D", "E"],
"edges": [
{"a": "A", "b": "B"},
{"a": "B", "b": "C"},
{"a": "C", "b": "D"},
{"a": "D", "b": "A", "dashed": True},
{"a": "P", "b": "A"},
{"a": "P", "b": "B"},
{"a": "P", "b": "C"},
{"a": "P", "b": "D"},
{"a": "A", "b": "C", "color": "aux", "dashed": True},
{"a": "B", "b": "D", "color": "aux", "dashed": True, "name": "Line_BD"},
],
"elements": {
"Line_BE": {"type": "line", "a": "B", "b": "E", "color": "emphasis", "depthTest": False},
"Plane_PAC": {"type": "plane", "pts": ["P", "A", "C"]},
"Normal_Vector": {"type": "arrow", "origin": "O", "dir": [0, 0, 1], "length": 1.5, "color": "normal"},
"Axis": {"type": "axes", "size": 3},
# 线段长度标注(已知条件:底边、对角线、高)
"Len_AB": {"type": "measure", "a": "A", "b": "B", "label": str(base_edge)},
"Len_AC": {"type": "measure", "a": "A", "b": "C", "label": diag_tex},
"Len_PO": {"type": "measure", "a": "P", "b": "O", "label": str(height)},
},
}
lesson = {
"language": "zh-CN",
"meta": "交互解题 · 线面角",
"title": f"正四棱锥P-ABCD中,底面边长为{base_edge},高为{height},E为PC中点,求直线BE与平面PAC所成角的正弦值",
"answerLabel": "直线 BE 与平面 PAC 所成角的正弦值",
"answerValue": f"${ans}$",
}
# ---- 步骤文案:数值占位由 kernel 计算结果填入(不心算)----
steps = [
{
"title": "建立空间直角坐标系",
"content": (
r"<p>首先,我们需要建立一个合适的空间直角坐标系来量化几何元素。</p>"
r"<p>取底面正方形 $ABCD$ 的中心 $O$ 为原点 $(0,0,0)$。</p>"
r"<p>让底面对角线 $AC$ 在 $x$ 轴,$BD$ 在 $y$ 轴,顶点 $P$ 在 $z$ 轴上。</p>"
r"<p>因为底面边长为 $" + str(base_edge) + r"$,所以两条对角线长为 $" + diag_tex + r"$,半对角线长为 $" + half_tex + r"$。于是关键点坐标为:</p>"
r"$$A" + mp["A"] + r", C" + mp["C"] + r"$$"
r"$$B" + mp["B"] + r", D" + mp["D"] + r"$$"
r"$$P" + mp["P"] + r"$$"
r"<p>这样 $AC \perp BD$ 关系更明显。</p>"
),
"highlight": ["Axis", "Len_AB", "Len_AC", "Len_PO"],
"cameraPos": {"x": 5, "y": 4, "z": 5},
},
{
"title": "计算中点 E 与向量 BE",
"content": (
r"<p>已知 $E$ 是侧棱 $PC$ 的中点。</p>"
r"<p>利用中点坐标公式:$E = \frac{P + C}{2}$</p>"
r"$$P" + mp["P"] + r", C" + mp["C"] + r"$$"
r"$$E = " + v["E"] + r"$$"
r"<p>接下来计算直线 $BE$ 的方向向量 $\vec{BE}$:</p>"
r"$$\vec{BE} = E - B = " + v["BE"] + r"$$"
),
"highlight": ["Line_BE"],
"cameraPos": {"x": 3, "y": 3, "z": 6},
},
{
"title": "确定平面 PAC 的法向量",
"content": (
r"<p>我们需要求直线 $BE$ 与平面 $PAC$ 的夹角。</p>"
r"<p>观察几何体特征:</p>"
r"<ul>"
r"<li>底面 $ABCD$ 是正方形,对角线互相垂直,即 $AC \perp BD$。</li>"
r"<li>顶点 $P$ 在底面的投影是中心 $O$,所以 $PO \perp AC$。</li>"
r"</ul>"
r"<p>因为 $AC \perp BD$ 且 $AC \perp PO$,所以直线 $BD \perp$ 平面 $PAC$。</p>"
r"<p>因此,平面 $PAC$ 的法向量 $\vec{n}$ 就是 $\vec{BD}$ 的方向:</p>"
r"$$\vec{n} = " + v["n"] + r"$$"
r"<p>简化取 $\vec{n} = " + v["n_simpl"] + r"$。</p>"
),
"highlight": ["Line_BE", "Plane_PAC", "Normal_Vector"],
"cameraPos": {"x": 4, "y": 5, "z": 2},
},
{
"title": "利用向量公式求解",
"content": (
r"<p>设直线 $BE$ 与平面 $PAC$ 所成角为 $\theta$。</p>"
r"<p>根据线面角公式:$\sin\theta = \dfrac{|\vec{BE} \cdot \vec{n}|}{|\vec{BE}|\,|\vec{n}|}$</p>"
r"<p>向量数据:</p>"
r"<ul>"
r"<li>$\vec{BE} = " + v["BE"] + r"$</li>"
r"<li>$\vec{n} = " + v["n_simpl"] + r"$</li>"
r"</ul>"
r"<p>计算数量积与模长:</p>"
r"$$\vec{BE} \cdot \vec{n} = " + v["dot"] + r", \quad |\vec{BE}| = " + v["norm_BE"] + r"$$"
r"<p>代入公式:</p>"
r"$$\sin\theta = " + v["sin"] + r"$$"
r"<p>所以,直线 $BE$ 与平面 $PAC$ 所成角的正弦值为 $" + ans + r"$。</p>"
),
"highlight": ["Line_BE", "Plane_PAC", "Normal_Vector"],
"cameraPos": {"x": 4, "y": 4.5, "z": 4},
},
]
return {"lesson": lesson, "steps": steps, "model": model, "_answer": ans}
def build_box_volume_data(lx=3, ly=4, lz=5) -> dict:
"""长方体 ABCD-A1B1C1D1,已知长宽高,求体积。演示非角度题型端到端出图。"""
V = gk.volume_box(lx, ly, lz)
pts = gk.cuboid(lx, ly, lz)
scale = 3.0 / max(lx, ly, lz)
tp = gk.to_three(pts, scale=scale)
topo = bodies.cuboid()
center = _centroid(tp)
ans = gk.tex(V)
model = {
"target": center,
"initialCamera": [center[0] + 5, center[1] + 4, center[2] + 6],
"points": tp,
"spheres": topo["spheres"],
"edges": topo["edges"],
"elements": {
"Edge_L": {"type": "line", "a": "A", "b": "B", "color": "emphasis"},
"Edge_W": {"type": "line", "a": "A", "b": "D", "color": "emphasis"},
"Edge_H": {"type": "line", "a": "A", "b": "A1", "color": "emphasis"},
"Axis": {"type": "axes", "size": max(tp_span(tp), 2.5)},
},
}
lesson = {
"language": "zh-CN",
"meta": "交互解题 · 体积",
"title": f"长方体ABCD-A₁B₁C₁D₁的长、宽、高分别为 {lx}、{ly}、{lz},求其体积",
"answerLabel": "长方体的体积",
"answerValue": f"${ans}$",
}
steps = [
{
"title": "明确长、宽、高",
"content": (
r"<p>以顶点 $A$ 为原点建立坐标系,三条从 $A$ 出发的棱即长、宽、高:</p>"
r"<ul>"
r"<li>长 $AB = " + str(lx) + r"$</li>"
r"<li>宽 $AD = " + str(ly) + r"$</li>"
r"<li>高 $AA_1 = " + str(lz) + r"$</li>"
r"</ul>"
),
"highlight": ["Edge_L", "Edge_W", "Edge_H", "Axis"],
"cameraPos": {"x": center[0] + 5, "y": center[1] + 4, "z": center[2] + 6},
},
{
"title": "应用长方体体积公式",
"content": (
r"<p>长方体体积等于长 × 宽 × 高:</p>"
r"$$V = AB \times AD \times AA_1$$"
),
"highlight": ["Edge_L", "Edge_W", "Edge_H"],
"cameraPos": {"x": center[0] + 4, "y": center[1] + 5, "z": center[2] + 5},
},
{
"title": "代入求值",
"content": (
r"$$V = " + str(lx) + r" \times " + str(ly) + r" \times " + str(lz) + r" = " + ans + r"$$"
r"<p>所以该长方体的体积为 $" + ans + r"$。</p>"
),
"highlight": ["Edge_L", "Edge_W", "Edge_H"],
"cameraPos": {"x": center[0] + 5, "y": center[1] + 4, "z": center[2] + 5},
},
]
return {"lesson": lesson, "steps": steps, "model": model, "_answer": ans}
def tp_span(three_points):
xs = [three_points[k][i] for k in three_points for i in range(3)]
return max(abs(x) for x in xs) + 0.5
def build_random_data(seed=0) -> dict:
"""随机出题:随机选题型与参数,求解,答案不规整就重抽,返回可渲染数据。
当前覆盖:长方体体积、正四棱锥线面角。可按同样的 resample 模式扩展更多题型。
"""
import random
rng = random.Random(seed)
kind = rng.choice(["box_volume", "pyramid_lpa"])
if kind == "box_volume":
lx, ly, lz = (rng.randint(2, 6) for _ in range(3))
return build_box_volume_data(lx, ly, lz)
# pyramid 线面角:重抽直到答案规整
for _ in range(50):
a = rng.choice([2, 4, 6])
h = rng.randint(1, 4)
sol = gk.solve_pyramid_line_plane_angle(base_edge=a, height=h)
if gk.is_clean(sol["_exact"]["sin_theta"]):
return build_data(base_edge=a, height=h)
# 兜底
return build_box_volume_data()
PROBLEMS = {
"pyramid": build_data,
"cube": build_cube_data,
"box": build_box_volume_data,
}
def main():
args = list(sys.argv[1:])
problem = "pyramid"
out = None
seed = 0
for a in args:
if a in PROBLEMS or a == "random":
problem = a
elif a.isdigit():
seed = int(a)
else:
out = Path(a)
if out is None:
# 默认写到“用户当前工作目录”(cwd),而不是技能自身目录
out = Path.cwd() / f"{problem}.html"
out.parent.mkdir(parents=True, exist_ok=True)
data = build_random_data(seed) if problem == "random" else PROBLEMS[problem]()
# --- 自检:最终步骤展示的答案必须等于答案卡的答案(同为 kernel 计算结果)---
final_step = data["steps"][-1]["content"]
assert data["_answer"] in final_step, "最终步骤未包含计算所得答案"
data.pop("_answer", None)
render_html(data, out)
print(f"已生成: {out}")
if __name__ == "__main__":
main()
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>立体几何交互教学</title>
<style>
:root {
--primary: #0ea5e9;
--primary-dark: #0284c7;
--accent: #d946ef;
--bg: #f8fafc;
--surface: #ffffff;
--surface-soft: #f1f5f9;
--text: #0f172a;
--muted: #475569;
--border: #e2e8f0;
--ring: rgba(14, 165, 233, 0.24);
--shadow-soft: 0 18px 45px rgba(15, 23, 42, 0.08);
--panel-shadow: 0 18px 42px rgba(15, 23, 42, 0.09);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
overflow: hidden;
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: #edf3f8;
color: var(--text);
display: flex;
gap: 10px;
height: 100vh;
padding: 10px;
}
button {
font: inherit;
}
.skip-link {
position: fixed;
left: 16px;
top: 16px;
z-index: 999;
padding: 10px 14px;
border-radius: 8px;
background: var(--text);
color: white;
text-decoration: none;
transform: translateY(-140%);
transition: transform 0.2s ease;
}
.skip-link:focus {
transform: translateY(0);
outline: 3px solid var(--ring);
}
/* 布局 */
#sidebar {
width: 420px;
background: var(--surface);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 24px;
display: flex;
flex-direction: column;
z-index: 10;
overflow: hidden;
box-shadow: var(--panel-shadow);
}
#canvas-container {
flex: 1;
position: relative;
background: #f8fafc;
min-width: 0;
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 24px;
overflow: hidden;
box-shadow: var(--panel-shadow);
}
/* 内容区域 */
.content-wrapper {
flex: 1;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
gap: 12px;
min-height: 0;
min-width: 0;
overflow: hidden;
padding: 16px;
}
.lesson-header {
display: grid;
gap: 12px;
}
.content-card {
min-width: 0;
padding: 14px;
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 14px;
background: #ffffff;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
}
.problem-card {
display: grid;
gap: 10px;
}
.lesson-meta {
display: flex;
align-items: center;
gap: 8px;
color: var(--primary-dark);
font-size: 12px;
font-weight: 800;
}
.lesson-meta::before {
content: "";
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--primary);
box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.12);
}
h1 {
font-size: 21px;
font-weight: 700;
margin: 0;
color: var(--text);
line-height: 1.4;
}
.answer-card {
display: grid;
grid-template-columns: 1fr auto;
gap: 14px;
align-items: center;
border: 1px solid #bae6fd;
background: #f8fbff;
box-shadow: var(--shadow-soft);
}
.answer-card span {
display: block;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.answer-card strong {
display: block;
color: var(--text);
font-size: 14px;
font-weight: 700;
line-height: 1.45;
}
.answer-value {
min-width: 96px;
padding: 9px 12px;
border-radius: 10px;
background: var(--text);
color: white;
text-align: center;
font-size: 20px;
font-weight: 800;
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.18);
}
.btn:focus-visible {
outline: 3px solid var(--ring);
outline-offset: 2px;
}
.step-badge {
justify-self: start;
display: inline-block;
background: var(--primary-dark);
color: white;
padding: 4px 12px;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
margin-bottom: 0;
}
.solution-card {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
gap: 12px;
min-height: 0;
min-width: 0;
overflow: hidden;
}
#step-container {
min-height: 0;
min-width: 0;
overflow: auto;
padding-right: 6px;
scroll-margin-top: 16px;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgba(14, 165, 233, 0.7) rgba(226, 232, 240, 0.75);
-webkit-overflow-scrolling: touch;
opacity: 1;
transform: translateY(0);
transition: opacity 0.28s ease, transform 0.28s ease;
}
#step-container::-webkit-scrollbar {
width: 10px;
height: 10px;
}
#step-container::-webkit-scrollbar-track {
background: rgba(226, 232, 240, 0.72);
border-radius: 999px;
}
#step-container::-webkit-scrollbar-thumb {
min-height: 36px;
border: 2px solid rgba(226, 232, 240, 0.72);
border-radius: 999px;
background: linear-gradient(180deg, rgba(14, 165, 233, 0.86), rgba(2, 132, 199, 0.86));
}
#step-container::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(2, 132, 199, 0.95), rgba(3, 105, 161, 0.95));
}
#step-container::-webkit-scrollbar-corner {
background: transparent;
}
#step-container.is-changing {
opacity: 0;
transform: translateY(6px);
}
.step-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: var(--primary-dark);
}
.step-content {
font-size: 15px;
line-height: 1.8;
color: var(--secondary, #334155);
}
.step-content mjx-container[display="true"] {
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
/* 公式溢出时:常驻显示横向滚动条 + 支持鼠标按住拖拽滑动 */
.step-content mjx-container[display="true"].is-scrollable {
overflow-x: scroll;
padding-bottom: 10px;
scrollbar-width: thin;
scrollbar-color: rgba(14, 165, 233, 0.85) rgba(226, 232, 240, 0.9);
cursor: grab;
touch-action: pan-x;
}
.step-content mjx-container[display="true"].is-scrollable.is-dragging {
cursor: grabbing;
-webkit-user-select: none;
user-select: none;
}
.step-content mjx-container[display="true"].is-scrollable::-webkit-scrollbar {
-webkit-appearance: none;
height: 9px;
}
.step-content mjx-container[display="true"].is-scrollable::-webkit-scrollbar-track {
background: rgba(226, 232, 240, 0.9);
border-radius: 999px;
}
.step-content mjx-container[display="true"].is-scrollable::-webkit-scrollbar-thumb {
border-radius: 999px;
background: linear-gradient(90deg, rgba(14, 165, 233, 0.95), rgba(2, 132, 199, 0.95));
}
.step-content mjx-container[display="true"].is-scrollable::-webkit-scrollbar-thumb:hover {
background: linear-gradient(90deg, rgba(2, 132, 199, 1), rgba(3, 105, 161, 1));
}
/* 行内公式过宽时允许整段横向滚动,避免溢出卡片 */
.step-content p,
.step-content li {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(14, 165, 233, 0.6) transparent;
}
.step-content p::-webkit-scrollbar,
.step-content li::-webkit-scrollbar {
height: 6px;
}
.step-content p::-webkit-scrollbar-thumb,
.step-content li::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(14, 165, 233, 0.6);
}
.math-term {
background: var(--surface-soft);
padding: 2px 6px;
border-radius: 4px;
color: var(--primary-dark);
font-weight: 500;
font-size: 0.9em;
}
/* 底部导航 */
.nav-bar {
padding: 16px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
gap: 12px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(14px);
}
.btn {
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
border: none;
font-size: 14px;
}
.btn-prev {
background: #f1f5f9;
color: #64748b;
}
.btn-prev:hover {
background: #e2e8f0;
color: #475569;
}
.btn-next {
background: var(--primary-dark);
color: white;
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.2);
}
.btn-next:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(14, 165, 233, 0.3);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* 3D 标签 */
.label {
font-size: 14px;
font-weight: 700;
color: var(--text);
text-shadow: -2px -2px 0 #fff, 2px -2px 0 #fff, -2px 2px 0 #fff, 2px 2px 0 #fff;
pointer-events: none;
user-select: none;
font-family: sans-serif;
}
/* 线段长度标注标签 */
.measure-label {
font-size: 13px;
font-weight: 700;
color: var(--primary-dark);
background: rgba(255, 255, 255, 0.86);
padding: 1px 7px;
border-radius: 7px;
border: 1px solid #bae6fd;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
pointer-events: none;
user-select: none;
white-space: nowrap;
line-height: 1.4;
}
/* 长度标注开关 */
#measure-toggle {
position: absolute;
left: 20px;
top: 20px;
z-index: 100;
padding: 8px 14px;
border: 1px solid var(--border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: var(--muted);
font-size: 13px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.08);
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
#measure-toggle.is-on {
background: var(--primary-dark);
color: #fff;
border-color: var(--primary-dark);
}
#measure-toggle:hover {
box-shadow: 0 6px 22px rgba(15, 23, 42, 0.14);
}
#measure-toggle:focus-visible {
outline: 3px solid var(--ring);
outline-offset: 2px;
}
@media (max-width: 768px) {
#measure-toggle { left: 14px; top: 14px; font-size: 12px; padding: 6px 11px; }
}
/* 提示气泡 */
.tip-bubble {
position: absolute;
top: 20px;
right: 20px;
background: white;
padding: 12px 16px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
font-size: 13px;
color: #64748b;
display: flex;
align-items: center;
gap: 8px;
pointer-events: none;
z-index: 100;
}
/* 动点实时数值面板 */
.readout-panel {
position: absolute;
left: 20px;
bottom: 20px;
background: rgba(255, 255, 255, 0.96);
padding: 12px 14px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.10);
font-size: 13px;
color: #334155;
min-width: 190px;
z-index: 100;
}
.readout-title {
font-size: 12px;
font-weight: 700;
color: var(--primary-dark);
margin-bottom: 8px;
}
.readout-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 3px 0;
}
.readout-row b {
color: var(--text);
font-variant-numeric: tabular-nums;
}
@media (max-width: 768px) {
.readout-panel { left: 14px; bottom: 14px; font-size: 12px; min-width: 150px; }
}
/* 响应式 */
@media (max-width: 768px) {
body {
flex-direction: column-reverse;
gap: 8px;
padding: 8px;
}
#sidebar {
width: 100%;
flex: 0 0 52vh;
border-radius: 20px;
box-shadow: var(--panel-shadow);
}
#canvas-container {
flex: 1 1 auto;
height: auto;
border-radius: 20px;
}
.content-wrapper {
gap: 10px;
padding: 10px;
}
.lesson-header { gap: 10px; }
.content-card {
padding: 12px;
border-radius: 12px;
}
.lesson-meta { font-size: 11px; }
h1 { font-size: 16px; line-height: 1.32; }
.answer-card {
grid-template-columns: 1fr auto;
gap: 10px;
}
.answer-card span { font-size: 11px; }
.answer-card strong { font-size: 12px; }
.answer-value {
width: fit-content;
min-width: 74px;
padding: 7px 9px;
font-size: 16px;
}
.solution-card { gap: 10px; }
.step-title {
margin-bottom: 10px;
font-size: 16px;
}
.step-content {
font-size: 14px;
line-height: 1.65;
}
.nav-bar {
padding: 10px 14px;
}
.tip-bubble {
top: 14px;
right: 14px;
max-width: calc(100% - 28px);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
</style>
<!-- Import Maps -->
<script type="importmap">
{
"imports": {
"three": "https://cdnjs.cloudflare.com/ajax/libs/three.js/0.160.0/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<!-- MathJax -->
<script>
window.MathJax = {
tex: {
inlineMath: [['$', '$']],
displayMath: [['$$','$$']],
processEscapes: true,
processEnvironments: true
},
options: {
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre']
},
startup: {
pageReady: () => {
return MathJax.startup.defaultPageReady().then(() => {
console.log('MathJax initial rendering complete');
});
}
}
};
</script>
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<!-- 课程数据(由 generate.py 注入;语言、题面、步骤、3D 模型全部数据驱动) -->
<script id="lesson-data" type="application/json">__LESSON_DATA__</script>
</head>
<body>
<a class="skip-link" id="skip-link" href="#step-container">跳到解题步骤</a>
<!-- 左侧控制面板 -->
<aside id="sidebar" aria-label="立体几何解题步骤面板">
<div class="content-wrapper">
<header class="lesson-header">
<section class="problem-card content-card" aria-labelledby="lesson-title">
<div class="lesson-meta" id="lesson-meta">交互解题</div>
<h1 id="lesson-title"></h1>
</section>
<section class="answer-card content-card" id="answer-card" aria-label="最终答案">
<div>
<span id="answer-title">最终答案</span>
<strong id="answer-summary"></strong>
</div>
<div class="answer-value" id="answer-value"></div>
</section>
</header>
<section class="solution-card content-card" id="solution-card" aria-label="解题步骤">
<div id="step-indicator" class="step-badge"></div>
<div id="step-container" role="region" aria-live="polite" tabindex="-1">
<!-- 动态内容 -->
</div>
</section>
</div>
<div class="nav-bar">
<button id="prev-btn" class="btn btn-prev" type="button" aria-controls="step-container" disabled>上一步</button>
<button id="next-btn" class="btn btn-next" type="button" aria-controls="step-container">下一步</button>
</div>
</aside>
<!-- 右侧 3D 画布 -->
<div id="canvas-container" role="img" aria-label="三维交互演示">
<div class="tip-bubble">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
</svg>
<span id="interaction-tip">左键旋转 · 滚轮缩放 · 右键平移</span>
</div>
</div>
<!-- 逻辑脚本 -->
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
// ===== 数据驱动:题面 / 步骤 / 3D 模型全部来自数据岛 =====
const DATA = JSON.parse(document.getElementById('lesson-data').textContent);
const lesson = DATA.lesson;
const steps = DATA.steps;
const model = DATA.model;
const defaultUI = {
pageTitle: lesson.title,
skipToSteps: "跳到解题步骤",
sidebarLabel: "立体几何解题步骤面板",
answerCardLabel: "最终答案",
answerTitle: "最终答案",
solutionCardLabel: "解题步骤",
sceneLabel: "立体几何三维交互演示",
interactionTip: "左键旋转 · 滚轮缩放 · 右键平移",
previous: "上一步",
next: "下一步",
finish: "完成",
stepTemplate: "步骤 {current} / {total}",
finishAria: "完成解题",
nextAriaTemplate: "进入步骤 {step}",
previousAriaTemplate: "返回步骤 {step}",
firstStepAria: "已经是第一步",
measureToggleOn: "长度标注:开",
measureToggleOff: "长度标注:关"
};
const ui = { ...defaultUI, ...(lesson.ui || {}) };
// 颜色板(数据里以语义名引用)
const COLORS = {
frame: 0x64748b,
aux: 0x94a3b8,
emphasis: 0xd946ef,
normal: 0xef4444,
plane: 0x0ea5e9,
point: 0x0f172a
};
function colorOf(name, fallback) {
return (name != null && COLORS[name] != null) ? COLORS[name] : fallback;
}
// 数学坐标 <-> three 坐标(draggable 实时数值用数学坐标计算)
const SCALE = model.scale || 1;
function threeFromMath(p) {
return new THREE.Vector3(p[0] * SCALE, p[2] * SCALE, p[1] * SCALE);
}
// Three.js 变量
let scene, camera, renderer, labelRenderer, controls;
let currentStep = 0;
let measuresOn = true; // 长度标注总开关
const objects = {}; // 存储所有3D对象以便管理
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cameraTargetPoint = new THREE.Vector3(
...(model.target || [0, 0.45, 0])
);
let targetCameraPosition = null;
function initializeLessonText() {
document.documentElement.lang = lesson.language || 'zh-CN';
document.title = ui.pageTitle || lesson.title;
document.getElementById('skip-link').textContent = ui.skipToSteps;
document.getElementById('sidebar').setAttribute('aria-label', ui.sidebarLabel);
document.getElementById('answer-card').setAttribute('aria-label', ui.answerCardLabel);
document.getElementById('answer-title').textContent = ui.answerTitle;
document.getElementById('solution-card').setAttribute('aria-label', ui.solutionCardLabel);
document.getElementById('canvas-container').setAttribute('aria-label', ui.sceneLabel);
document.getElementById('interaction-tip').textContent = ui.interactionTip;
document.getElementById('prev-btn').textContent = ui.previous;
document.getElementById('lesson-meta').textContent = lesson.meta || '';
document.getElementById('lesson-title').textContent = lesson.title;
document.getElementById('answer-summary').textContent = lesson.answerLabel || '';
document.getElementById('answer-value').textContent = lesson.answerValue || '';
}
function markScrollableMath(root) {
const blocks = root.querySelectorAll('.step-content mjx-container[display="true"]');
blocks.forEach((el) => {
// 公式实际宽度超出可视宽度时,强制常驻显示横向滚动条
const overflowing = el.scrollWidth - el.clientWidth > 1;
el.classList.toggle('is-scrollable', overflowing);
});
}
function typesetMath(container) {
// MathJax 异步加载:未就绪时轮询重试,避免“缓存快加载”时
// 在 CSS2D 标签挂载/数据注入之前就错过排版,导致 3D 长度标签显示原始 $…$。
const run = () => {
if (window.MathJax && window.MathJax.typesetPromise) {
window.MathJax.typesetPromise([container])
.then(() => markScrollableMath(container))
.catch((err) => console.log(err));
} else {
setTimeout(run, 120);
}
};
run();
}
// 鼠标按住拖拽即可横向滑动溢出的公式(不依赖系统滚动条)
function enableDragScroll(scope) {
let dragEl = null;
let startX = 0;
let startScroll = 0;
let pointerId = null;
let moved = false;
function findScrollable(target) {
let el = target;
while (el && el !== scope) {
if (el.scrollWidth - el.clientWidth > 1) {
const ox = getComputedStyle(el).overflowX;
if (ox === 'auto' || ox === 'scroll') return el;
}
el = el.parentElement;
}
return null;
}
scope.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'touch') return; // 触屏用原生滑动
const el = findScrollable(e.target);
if (!el) return;
dragEl = el;
startX = e.clientX;
startScroll = el.scrollLeft;
pointerId = e.pointerId;
moved = false;
el.classList.add('is-dragging');
try { el.setPointerCapture(pointerId); } catch (_) {}
});
scope.addEventListener('pointermove', (e) => {
if (!dragEl) return;
const dx = e.clientX - startX;
if (Math.abs(dx) > 2) moved = true;
dragEl.scrollLeft = startScroll - dx;
e.preventDefault();
});
function end() {
if (!dragEl) return;
try { dragEl.releasePointerCapture(pointerId); } catch (_) {}
dragEl.classList.remove('is-dragging');
dragEl = null;
pointerId = null;
}
scope.addEventListener('pointerup', end);
scope.addEventListener('pointercancel', end);
scope.addEventListener('lostpointercapture', end);
// 拖拽过程中阻止误触发的点击
scope.addEventListener('click', (e) => {
if (moved) { e.stopPropagation(); e.preventDefault(); moved = false; }
}, true);
}
function goToStep(index) {
currentStep = Math.max(0, Math.min(index, steps.length - 1));
updateUI();
}
// 初始化场景
function init() {
const container = document.getElementById('canvas-container');
// 1. 场景
scene = new THREE.Scene();
scene.background = new THREE.Color(0xf8fafc);
scene.fog = new THREE.Fog(0xf8fafc, 10, 50);
// 2. 相机
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 100);
const ic = model.initialCamera || [5, 4, 5];
camera.position.set(ic[0], ic[1], ic[2]);
// 3. 渲染器
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);
labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(container.clientWidth, container.clientHeight);
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0px';
labelRenderer.domElement.style.pointerEvents = 'none';
container.appendChild(labelRenderer.domElement);
// 4. 控制器
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = !prefersReducedMotion;
controls.dampingFactor = 0.05;
// 5. 灯光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(5, 10, 7);
dirLight.castShadow = true;
scene.add(dirLight);
// 6. 辅助网格
const gridHelper = new THREE.GridHelper(20, 20, 0xcbd5e1, 0xe2e8f0);
scene.add(gridHelper);
// 7. 构建几何模型(通用渲染器:读 model 数据)
buildModel();
// 8. 监听调整大小
window.addEventListener('resize', onWindowResize);
// 9. 初始化UI
initializeLessonText();
enableDragScroll(document.getElementById('step-container'));
updateUI();
// 先渲染一帧,让 CSS2D 长度标签 DOM 挂载,再排版(否则缓存秒开时会漏排)
labelRenderer.render(scene, camera);
typesetMath(document.getElementById('canvas-container'));
buildMeasureToggle();
// 10. 开始动画
animate();
}
// ===== 通用几何工具 =====
function v3(arr) {
return new THREE.Vector3(arr[0], arr[1], arr[2]);
}
const POINTS = {}; // name -> THREE.Vector3
function pt(nameOrCoords) {
if (Array.isArray(nameOrCoords)) return v3(nameOrCoords);
return POINTS[nameOrCoords].clone();
}
function createTubeSegment(p1, p2, radius, material) {
const direction = new THREE.Vector3().subVectors(p2, p1);
const length = direction.length();
const geometry = new THREE.CylinderGeometry(radius, radius, length, 14);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.copy(new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5));
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.normalize());
return mesh;
}
// 返回一条线(实/虚),不自动加入场景,由调用方决定父节点
function makeLine(p1, p2, color = COLORS.frame, dashed = false, emphasis = false) {
const material = new THREE.MeshBasicMaterial({ color });
if (dashed) {
const group = new THREE.Group();
const direction = new THREE.Vector3().subVectors(p2, p1);
const length = direction.length();
const unit = direction.clone().normalize();
const dashSize = 0.22;
const gapSize = 0.13;
const radius = 0.0055;
for (let start = 0; start < length; start += dashSize + gapSize) {
const end = Math.min(start + dashSize, length);
const dashStart = p1.clone().add(unit.clone().multiplyScalar(start));
const dashEnd = p1.clone().add(unit.clone().multiplyScalar(end));
group.add(createTubeSegment(dashStart, dashEnd, radius, material));
}
return group;
}
const radius = emphasis ? 0.012 : 0.0075;
return createTubeSegment(p1, p2, radius, material);
}
function makePlane(points) {
const geo = new THREE.BufferGeometry();
const flat = [];
points.forEach((p) => { flat.push(p.x, p.y, p.z); });
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(flat), 3));
if (points.length === 3) {
geo.setIndex([0, 1, 2]);
} else if (points.length === 4) {
geo.setIndex([0, 1, 2, 0, 2, 3]);
}
const mat = new THREE.MeshBasicMaterial({
color: COLORS.plane,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.2
});
return new THREE.Mesh(geo, mat);
}
function buildModel() {
// 1. 点坐标
for (const [name, coords] of Object.entries(model.points)) {
POINTS[name] = v3(coords);
}
// 2. 顶点小球 + 标签
const sphereNames = model.spheres || Object.keys(model.points);
const dragName = model.draggable ? model.draggable.point : null;
sphereNames.forEach((name) => {
const isDrag = name === dragName;
const geometry = new THREE.SphereGeometry(isDrag ? 0.12 : 0.06, 18, 18);
const material = new THREE.MeshBasicMaterial({ color: isDrag ? COLORS.emphasis : COLORS.point });
const sphere = new THREE.Mesh(geometry, material);
sphere.position.copy(POINTS[name]);
scene.add(sphere);
objects[`Point_${name}`] = sphere;
const div = document.createElement('div');
div.className = 'label';
div.textContent = name;
div.style.marginTop = '-1em';
const label = new CSS2DObject(div);
label.position.set(0, 0.15, 0);
sphere.add(label);
});
// 3. 骨架棱(始终可见)
const frame = new THREE.Group();
(model.edges || []).forEach((e) => {
const line = makeLine(pt(e.a), pt(e.b), colorOf(e.color, COLORS.frame), !!e.dashed);
frame.add(line);
if (e.name) objects[e.name] = line;
});
scene.add(frame);
objects['Frame'] = frame;
// 5. 可切换的命名元素(默认隐藏,按步骤 highlight 显示;高亮永远画在最前)
const elements = model.elements || {};
for (const [key, el] of Object.entries(elements)) {
const obj = createElementObj(el);
if (obj) {
obj.visible = false;
scene.add(obj);
objects[key] = obj;
}
}
// 6. 动点拖拽(若声明)
if (model.draggable) setupDraggable();
}
// 由元素定义创建 3D 对象(建模与拖拽重建共用)
function createElementObj(el) {
let obj = null;
if (el.type === 'line') {
obj = makeLine(pt(el.a), pt(el.b), colorOf(el.color, COLORS.emphasis), !!el.dashed, true);
if (el.depthTest === false) {
const setNoDepth = (m) => { if (m.material) { m.material.depthTest = false; m.renderOrder = 1; } };
if (obj.isGroup) obj.children.forEach(setNoDepth); else setNoDepth(obj);
}
} else if (el.type === 'plane') {
obj = makePlane((el.pts || []).map((p) => pt(p)));
} else if (el.type === 'arrow') {
const dir = v3(el.dir).normalize();
const origin = pt(el.origin);
const head = el.head || [0.2, 0.1];
obj = new THREE.ArrowHelper(dir, origin, el.length || 1.5, colorOf(el.color, COLORS.normal), head[0], head[1]);
} else if (el.type === 'sphere') {
// 球面:translucent 实心球 + 经纬线框,用于外接球/球 O 等
const center = pt(el.center);
const radius = el.radius || 1;
const col = colorOf(el.color, COLORS.plane);
const group = new THREE.Group();
const solid = new THREE.Mesh(
new THREE.SphereGeometry(radius, 40, 32),
new THREE.MeshBasicMaterial({ color: col, transparent: true, opacity: el.opacity != null ? el.opacity : 0.12, side: THREE.DoubleSide, depthWrite: false })
);
const wire = new THREE.LineSegments(
new THREE.WireframeGeometry(new THREE.SphereGeometry(radius, 24, 16)),
new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: 0.25 })
);
group.add(solid); group.add(wire);
group.position.copy(center);
obj = group;
} else if (el.type === 'axes') {
obj = new THREE.AxesHelper(el.size || 3);
} else if (el.type === 'measure') {
// 线段长度标注:中点朝几何体外侧偏移,贴一个 MathJax 长度标签
// 注意:CSS2DRenderer 只看 CSS2DObject 自身的 .visible(不看父级),
// 所以直接返回定位好的 CSS2DObject,highlight 才能正确开关它。
const mid = pt(el.a).add(pt(el.b)).multiplyScalar(0.5);
const target = v3(model.target || [0, 0.45, 0]);
let dir = mid.clone().sub(target);
if (dir.lengthSq() < 1e-6) dir.set(0, 1, 0);
dir.normalize().multiplyScalar(el.offset != null ? el.offset : 0.24);
const div = document.createElement('div');
div.className = 'measure-label';
div.innerHTML = '$' + (el.label != null ? el.label : '') + '$';
obj = new CSS2DObject(div);
obj.position.copy(mid.add(dir));
}
return obj;
}
// ===== 动点拖拽 + 实时数值 =====
const MATH = {}; // name -> 数学坐标数组(draggable 用)
function setupDraggable() {
const dg = model.draggable;
for (const [k, v] of Object.entries(model.mathPoints || {})) MATH[k] = v.slice();
const moving = new Set([dg.point, ...(dg.dependent || []).map((d) => d.name)]);
// 哪些元素引用了移动点 -> 拖拽时重建
const refsMoving = (el) => {
const names = [el.a, el.b, el.origin, ...(el.pts || [])].filter(Boolean);
return names.some((n) => moving.has(n));
};
const depElements = Object.entries(model.elements || {})
.filter(([, el]) => refsMoving(el)).map(([k]) => k);
const segA = threeFromMath(MATH[dg.along[0]]);
const segB = threeFromMath(MATH[dg.along[1]]);
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let dragging = false;
function setParam(t) {
t = Math.max(0, Math.min(1, t));
// 数学坐标:动点沿 along 线性插值
const a = MATH[dg.along[0]], b = MATH[dg.along[1]];
MATH[dg.point] = [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]), a[2] + t * (b[2] - a[2])];
// 依赖点(目前支持 midpoint)
(dg.dependent || []).forEach((d) => {
if (d.kind === 'midpoint') {
const p = MATH[d.of[0]], q = MATH[d.of[1]];
MATH[d.name] = [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2, (p[2] + q[2]) / 2];
}
});
// 更新 three 坐标 + 小球位置
moving.forEach((n) => {
POINTS[n] = threeFromMath(MATH[n]);
if (objects[`Point_${n}`]) objects[`Point_${n}`].position.copy(POINTS[n]);
});
// 重建依赖元素(保留可见性)
depElements.forEach((key) => {
const old = objects[key];
const vis = old ? old.visible : false;
if (old) scene.remove(old);
const obj = createElementObj(model.elements[key]);
if (obj) { obj.visible = vis; scene.add(obj); objects[key] = obj; }
});
updateReadouts(t);
}
function pointerNDC(e) {
const r = renderer.domElement.getBoundingClientRect();
pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;
}
renderer.domElement.addEventListener('pointerdown', (e) => {
pointerNDC(e);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObject(objects[`Point_${dg.point}`], true);
if (hit.length) {
dragging = true;
controls.enabled = false;
renderer.domElement.style.cursor = 'grabbing';
}
});
window.addEventListener('pointermove', (e) => {
if (!dragging) return;
pointerNDC(e);
raycaster.setFromCamera(pointer, camera);
// 射线到约束线段的最近点 -> 求参数 t
const closest = new THREE.Vector3();
raycaster.ray.distanceSqToSegment(segA, segB, null, closest);
const seg = new THREE.Vector3().subVectors(segB, segA);
const t = new THREE.Vector3().subVectors(closest, segA).dot(seg) / seg.lengthSq();
setParam(t);
});
window.addEventListener('pointerup', () => {
dragging = false;
controls.enabled = true;
renderer.domElement.style.cursor = '';
});
// 初始:放到题目设定位置
setParam(dg.t != null ? dg.t : 0.5);
buildReadoutPanel();
setParam(dg.t != null ? dg.t : 0.5);
}
// ---- 实时数值(数学坐标下计算真实几何量)----
function mv(name) { return MATH[name]; }
function vsub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
function vdot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function vcross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function vnorm(a) { return Math.sqrt(vdot(a, a)); }
function computeReadout(r) {
if (r.type === 'volume_tetra') {
const [P0, P1, P2, P3] = r.pts.map(mv);
return Math.abs(vdot(vsub(P1, P0), vcross(vsub(P2, P0), vsub(P3, P0)))) / 6;
}
if (r.type === 'length') {
return vnorm(vsub(mv(r.pts[1]), mv(r.pts[0])));
}
if (r.type === 'line_plane_angle_sin') {
const v = vsub(mv(r.line[1]), mv(r.line[0]));
const n = vcross(vsub(mv(r.plane[1]), mv(r.plane[0])), vsub(mv(r.plane[2]), mv(r.plane[0])));
return Math.abs(vdot(v, n)) / (vnorm(v) * vnorm(n));
}
return null;
}
function buildReadoutPanel() {
if (!(model.draggable.readouts || []).length) return;
if (document.getElementById('readout-panel')) return;
const panel = document.createElement('div');
panel.id = 'readout-panel';
panel.className = 'readout-panel';
document.getElementById('canvas-container').appendChild(panel);
}
function updateReadouts(t) {
const panel = document.getElementById('readout-panel');
if (!panel) return;
const dg = model.draggable;
const atStd = dg.t != null && Math.abs(t - dg.t) < 0.01;
const rows = (dg.readouts || []).map((r) => {
const val = computeReadout(r);
const num = (val == null) ? '—' : (Math.round(val * 1000) / 1000);
return `<div class="readout-row"><span>${r.label}</span><b>${num}</b></div>`;
});
const hint = (dg.standardLabel || '题目设定位') + (atStd ? ' ✓' : '');
panel.innerHTML =
`<div class="readout-title">拖动点 ${dg.point} 探索 · ${hint}</div>` + rows.join('');
}
// 长度标注总开关(仅当存在 measure 元素时出现)
function buildMeasureToggle() {
const elements = model.elements || {};
const hasMeasure = Object.values(elements).some((el) => el.type === 'measure');
if (!hasMeasure) return;
const btn = document.createElement('button');
btn.id = 'measure-toggle';
btn.type = 'button';
const sync = () => {
btn.textContent = measuresOn ? ui.measureToggleOn : ui.measureToggleOff;
btn.classList.toggle('is-on', measuresOn);
btn.setAttribute('aria-pressed', String(measuresOn));
};
sync();
btn.addEventListener('click', () => {
measuresOn = !measuresOn;
sync();
updateSceneState();
});
document.getElementById('canvas-container').appendChild(btn);
}
function updateUI() {
// 更新文本
const stepData = steps[currentStep];
document.getElementById('step-indicator').textContent = ui.stepTemplate
.replace('{current}', currentStep + 1)
.replace('{total}', steps.length);
const container = document.getElementById('step-container');
container.innerHTML = `
<div class="step-title">${stepData.title}</div>
<div class="step-content">${stepData.content}</div>
`;
container.scrollTop = 0;
// 重新渲染 MathJax
typesetMath(document.getElementById('sidebar'));
if (!prefersReducedMotion) {
container.getAnimations().forEach((animation) => animation.cancel());
container.animate([
{ opacity: 0, transform: 'translateY(8px)' },
{ opacity: 1, transform: 'translateY(0)' }
], {
duration: 280,
easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)'
});
} else {
container.classList.remove('is-changing');
}
// 更新按钮状态
document.getElementById('prev-btn').disabled = currentStep === 0;
document.getElementById('next-btn').textContent = currentStep === steps.length - 1 ? ui.finish : ui.next;
document.getElementById('next-btn').setAttribute(
'aria-label',
currentStep === steps.length - 1
? ui.finishAria
: ui.nextAriaTemplate.replace('{step}', currentStep + 2)
);
document.getElementById('prev-btn').setAttribute(
'aria-label',
currentStep === 0
? ui.firstStepAria
: ui.previousAriaTemplate.replace('{step}', currentStep)
);
// 更新 3D 场景状态
updateSceneState();
}
function updateSceneState() {
const data = steps[currentStep];
// 1. 先隐藏所有可切换的命名元素
const elements = model.elements || {};
for (const key of Object.keys(elements)) {
if (objects[key]) objects[key].visible = false;
}
// 2. 按当前步骤的 highlight 绝对显示(列出该步需要可见的元素)
if (Array.isArray(data.highlight)) {
data.highlight.forEach((key) => {
if (!objects[key]) return;
const el = elements[key];
if (el && el.type === 'measure' && !measuresOn) return; // 长度标注总开关:关时不显示
objects[key].visible = true;
});
}
// 3. 移动相机 (平滑过渡)
if (data.cameraPos) {
const targetPos = new THREE.Vector3(data.cameraPos.x, data.cameraPos.y, data.cameraPos.z);
if (prefersReducedMotion) {
camera.position.copy(targetPos);
controls.target.copy(cameraTargetPoint);
controls.update();
} else {
targetCameraPosition = targetPos;
}
}
// 本步新显示的长度标签需排版:先渲染一帧让其 display 生效(MathJax 跳过 display:none),再排版
labelRenderer.render(scene, camera);
typesetMath(document.getElementById('canvas-container'));
}
function onWindowResize() {
const container = document.getElementById('canvas-container');
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
labelRenderer.setSize(container.clientWidth, container.clientHeight);
// 面板宽度变化后,重新评估公式是否需要常驻横向滚动条
markScrollableMath(document.getElementById('sidebar'));
}
function animate() {
requestAnimationFrame(animate);
if (targetCameraPosition) {
camera.position.lerp(targetCameraPosition, 0.07);
controls.target.lerp(cameraTargetPoint, 0.07);
if (camera.position.distanceTo(targetCameraPosition) < 0.015) {
camera.position.copy(targetCameraPosition);
controls.target.copy(cameraTargetPoint);
targetCameraPosition = null;
}
}
controls.update();
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
}
// 事件绑定
document.getElementById('prev-btn').addEventListener('click', () => {
if (currentStep > 0) {
goToStep(currentStep - 1);
}
});
document.getElementById('next-btn').addEventListener('click', () => {
if (currentStep < steps.length - 1) {
goToStep(currentStep + 1);
}
});
// 启动
init();
</script>
</body>
</html>