立体几何解题 → 交互网页 这个技能产出什么 一个可直接用浏览器打开的单页 HTML:左侧题面/答案/分步解析(公式用 MathJax), 右侧是题目对应的 3D 模型(Three.js,可旋转缩放,分步高亮关键元素并切换镜头)。 形态与 一致。 依赖(重要) 计算核心 依赖 sympy 。运行脚本前先确认有一个能 import sympy 的 :跑 。 缺库时的处理(重要) :若 import 报错(sympy 或后续用到的任何库都同理), 先询问用户是否安装 , 得到同意后再帮忙安装( ),或换一个已装该库的解释器; 不要未经询问直接装 。 下文命令里的 均指这个能跑通依赖的解释器。 工作流程 第 1 步:得到 problem spec(三入口归一) 把题目整理成结构化 spec(格式见 ):几何体类型与尺寸、 已知构造点/条件、所求类型与对象、 语言 。 - 文字题目 :直接抽取。 - 图片 :用视觉读图抽取,并 把识别到的题目回显给用户确认 (题面/几何体/尺寸/所求/语言)后再继续。 - 随机出题 :选定几何体与题型,用 kernel 随机参数求解,答案不规整就重抽。 输出语言跟随提示词语言 :英文提示 → 英文网页,中文 → 中文。spec 里记下 。 第 2 步:用 kernel 精确计算(不要心算) 按 的建系约定与解法配方,调用 : 得到精确坐标、关键向量、法向…

, '

立体几何解题 → 交互网页 这个技能产出什么 一个可直接用浏览器打开的单页 HTML:左侧题面/答案/分步解析(公式用 MathJax), 右侧是题目对应的 3D 模型(Three.js,可旋转缩放,分步高亮关键元素并切换镜头)。 形态与 一致。 依赖(重要) 计算核心 依赖 sympy 。运行脚本前先确认有一个能 import sympy 的 :跑 。 缺库时的处理(重要) :若 import 报错(sympy 或后续用到的任何库都同理), 先询问用户是否安装 , 得到同意后再帮忙安装( ),或换一个已装该库的解释器; 不要未经询问直接装 。 下文命令里的 均指这个能跑通依赖的解释器。 工作流程 第 1 步:得到 problem spec(三入口归一) 把题目整理成结构化 spec(格式见 ):几何体类型与尺寸、 已知构造点/条件、所求类型与对象、 语言 。 - 文字题目 :直接抽取。 - 图片 :用视觉读图抽取,并 把识别到的题目回显给用户确认 (题面/几何体/尺寸/所求/语言)后再继续。 - 随机出题 :选定几何体与题型,用 kernel 随机参数求解,答案不规整就重抽。 输出语言跟随提示词语言 :英文提示 → 英文网页,中文 → 中文。spec 里记下 。 第 2 步:用 kernel 精确计算(不要心算) 按 的建系约定与解法配方,调用 : 得到精确坐标、关键向量、法向…

]],\n displayMath: [['$','$']],\n processEscapes: true,\n processEnvironments: true\n },\n options: {\n skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre']\n },\n startup: {\n pageReady: () => {\n return MathJax.startup.defaultPageReady().then(() => {\n console.log('MathJax initial rendering complete');\n });\n }\n }\n };\n \u003c/script>\n \u003cscript id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js\">\u003c/script>\n\n \u003c!-- 课程数据(由 generate.py 注入;语言、题面、步骤、3D 模型全部数据驱动) -->\n \u003cscript id=\"lesson-data\" type=\"application/json\">__LESSON_DATA__\u003c/script>\n\u003c/head>\n\u003cbody>\n\n \u003ca class=\"skip-link\" id=\"skip-link\" href=\"#step-container\">跳到解题步骤\u003c/a>\n\n \u003c!-- 左侧控制面板 -->\n \u003caside id=\"sidebar\" aria-label=\"立体几何解题步骤面板\">\n \u003cdiv class=\"content-wrapper\">\n \u003cheader class=\"lesson-header\">\n \u003csection class=\"problem-card content-card\" aria-labelledby=\"lesson-title\">\n \u003cdiv class=\"lesson-meta\" id=\"lesson-meta\">交互解题\u003c/div>\n \u003ch1 id=\"lesson-title\">\u003c/h1>\n \u003c/section>\n \u003csection class=\"answer-card content-card\" id=\"answer-card\" aria-label=\"最终答案\">\n \u003cdiv>\n \u003cspan id=\"answer-title\">最终答案\u003c/span>\n \u003cstrong id=\"answer-summary\">\u003c/strong>\n \u003c/div>\n \u003cdiv class=\"answer-value\" id=\"answer-value\">\u003c/div>\n \u003c/section>\n \u003c/header>\n \u003csection class=\"solution-card content-card\" id=\"solution-card\" aria-label=\"解题步骤\">\n \u003cdiv id=\"step-indicator\" class=\"step-badge\">\u003c/div>\n \u003cdiv id=\"step-container\" role=\"region\" aria-live=\"polite\" tabindex=\"-1\">\n \u003c!-- 动态内容 -->\n \u003c/div>\n \u003c/section>\n \u003c/div>\n \u003cdiv class=\"nav-bar\">\n \u003cbutton id=\"prev-btn\" class=\"btn btn-prev\" type=\"button\" aria-controls=\"step-container\" disabled>上一步\u003c/button>\n \u003cbutton id=\"next-btn\" class=\"btn btn-next\" type=\"button\" aria-controls=\"step-container\">下一步\u003c/button>\n \u003c/div>\n \u003c/aside>\n\n \u003c!-- 右侧 3D 画布 -->\n \u003cdiv id=\"canvas-container\" role=\"img\" aria-label=\"三维交互演示\">\n \u003cdiv class=\"tip-bubble\">\n \u003csvg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n \u003cpath d=\"M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7\"/>\n \u003c/svg>\n \u003cspan id=\"interaction-tip\">左键旋转 · 滚轮缩放 · 右键平移\u003c/span>\n \u003c/div>\n \u003c/div>\n\n \u003c!-- 逻辑脚本 -->\n \u003cscript type=\"module\">\n import * as THREE from 'three';\n import { OrbitControls } from 'three/addons/controls/OrbitControls.js';\n import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';\n\n // ===== 数据驱动:题面 / 步骤 / 3D 模型全部来自数据岛 =====\n const DATA = JSON.parse(document.getElementById('lesson-data').textContent);\n const lesson = DATA.lesson;\n const steps = DATA.steps;\n const model = DATA.model;\n\n const defaultUI = {\n pageTitle: lesson.title,\n skipToSteps: \"跳到解题步骤\",\n sidebarLabel: \"立体几何解题步骤面板\",\n answerCardLabel: \"最终答案\",\n answerTitle: \"最终答案\",\n solutionCardLabel: \"解题步骤\",\n sceneLabel: \"立体几何三维交互演示\",\n interactionTip: \"左键旋转 · 滚轮缩放 · 右键平移\",\n previous: \"上一步\",\n next: \"下一步\",\n finish: \"完成\",\n stepTemplate: \"步骤 {current} / {total}\",\n finishAria: \"完成解题\",\n nextAriaTemplate: \"进入步骤 {step}\",\n previousAriaTemplate: \"返回步骤 {step}\",\n firstStepAria: \"已经是第一步\",\n measureToggleOn: \"长度标注:开\",\n measureToggleOff: \"长度标注:关\"\n };\n const ui = { ...defaultUI, ...(lesson.ui || {}) };\n\n // 颜色板(数据里以语义名引用)\n const COLORS = {\n frame: 0x64748b,\n aux: 0x94a3b8,\n emphasis: 0xd946ef,\n normal: 0xef4444,\n plane: 0x0ea5e9,\n point: 0x0f172a\n };\n function colorOf(name, fallback) {\n return (name != null && COLORS[name] != null) ? COLORS[name] : fallback;\n }\n\n // 数学坐标 \u003c-> three 坐标(draggable 实时数值用数学坐标计算)\n const SCALE = model.scale || 1;\n function threeFromMath(p) {\n return new THREE.Vector3(p[0] * SCALE, p[2] * SCALE, p[1] * SCALE);\n }\n\n // Three.js 变量\n let scene, camera, renderer, labelRenderer, controls;\n let currentStep = 0;\n let measuresOn = true; // 长度标注总开关\n const objects = {}; // 存储所有3D对象以便管理\n const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n const cameraTargetPoint = new THREE.Vector3(\n ...(model.target || [0, 0.45, 0])\n );\n let targetCameraPosition = null;\n\n function initializeLessonText() {\n document.documentElement.lang = lesson.language || 'zh-CN';\n document.title = ui.pageTitle || lesson.title;\n document.getElementById('skip-link').textContent = ui.skipToSteps;\n document.getElementById('sidebar').setAttribute('aria-label', ui.sidebarLabel);\n document.getElementById('answer-card').setAttribute('aria-label', ui.answerCardLabel);\n document.getElementById('answer-title').textContent = ui.answerTitle;\n document.getElementById('solution-card').setAttribute('aria-label', ui.solutionCardLabel);\n document.getElementById('canvas-container').setAttribute('aria-label', ui.sceneLabel);\n document.getElementById('interaction-tip').textContent = ui.interactionTip;\n document.getElementById('prev-btn').textContent = ui.previous;\n document.getElementById('lesson-meta').textContent = lesson.meta || '';\n document.getElementById('lesson-title').textContent = lesson.title;\n document.getElementById('answer-summary').textContent = lesson.answerLabel || '';\n document.getElementById('answer-value').textContent = lesson.answerValue || '';\n }\n\n function markScrollableMath(root) {\n const blocks = root.querySelectorAll('.step-content mjx-container[display=\"true\"]');\n blocks.forEach((el) => {\n // 公式实际宽度超出可视宽度时,强制常驻显示横向滚动条\n const overflowing = el.scrollWidth - el.clientWidth > 1;\n el.classList.toggle('is-scrollable', overflowing);\n });\n }\n\n function typesetMath(container) {\n // MathJax 异步加载:未就绪时轮询重试,避免“缓存快加载”时\n // 在 CSS2D 标签挂载/数据注入之前就错过排版,导致 3D 长度标签显示原始 $…$。\n const run = () => {\n if (window.MathJax && window.MathJax.typesetPromise) {\n window.MathJax.typesetPromise([container])\n .then(() => markScrollableMath(container))\n .catch((err) => console.log(err));\n } else {\n setTimeout(run, 120);\n }\n };\n run();\n }\n\n // 鼠标按住拖拽即可横向滑动溢出的公式(不依赖系统滚动条)\n function enableDragScroll(scope) {\n let dragEl = null;\n let startX = 0;\n let startScroll = 0;\n let pointerId = null;\n let moved = false;\n\n function findScrollable(target) {\n let el = target;\n while (el && el !== scope) {\n if (el.scrollWidth - el.clientWidth > 1) {\n const ox = getComputedStyle(el).overflowX;\n if (ox === 'auto' || ox === 'scroll') return el;\n }\n el = el.parentElement;\n }\n return null;\n }\n\n scope.addEventListener('pointerdown', (e) => {\n if (e.pointerType === 'touch') return; // 触屏用原生滑动\n const el = findScrollable(e.target);\n if (!el) return;\n dragEl = el;\n startX = e.clientX;\n startScroll = el.scrollLeft;\n pointerId = e.pointerId;\n moved = false;\n el.classList.add('is-dragging');\n try { el.setPointerCapture(pointerId); } catch (_) {}\n });\n\n scope.addEventListener('pointermove', (e) => {\n if (!dragEl) return;\n const dx = e.clientX - startX;\n if (Math.abs(dx) > 2) moved = true;\n dragEl.scrollLeft = startScroll - dx;\n e.preventDefault();\n });\n\n function end() {\n if (!dragEl) return;\n try { dragEl.releasePointerCapture(pointerId); } catch (_) {}\n dragEl.classList.remove('is-dragging');\n dragEl = null;\n pointerId = null;\n }\n\n scope.addEventListener('pointerup', end);\n scope.addEventListener('pointercancel', end);\n scope.addEventListener('lostpointercapture', end);\n // 拖拽过程中阻止误触发的点击\n scope.addEventListener('click', (e) => {\n if (moved) { e.stopPropagation(); e.preventDefault(); moved = false; }\n }, true);\n }\n\n function goToStep(index) {\n currentStep = Math.max(0, Math.min(index, steps.length - 1));\n updateUI();\n }\n\n // 初始化场景\n function init() {\n const container = document.getElementById('canvas-container');\n\n // 1. 场景\n scene = new THREE.Scene();\n scene.background = new THREE.Color(0xf8fafc);\n scene.fog = new THREE.Fog(0xf8fafc, 10, 50);\n\n // 2. 相机\n camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 100);\n const ic = model.initialCamera || [5, 4, 5];\n camera.position.set(ic[0], ic[1], ic[2]);\n\n // 3. 渲染器\n renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });\n renderer.setSize(container.clientWidth, container.clientHeight);\n renderer.setPixelRatio(window.devicePixelRatio);\n renderer.shadowMap.enabled = true;\n container.appendChild(renderer.domElement);\n\n labelRenderer = new CSS2DRenderer();\n labelRenderer.setSize(container.clientWidth, container.clientHeight);\n labelRenderer.domElement.style.position = 'absolute';\n labelRenderer.domElement.style.top = '0px';\n labelRenderer.domElement.style.pointerEvents = 'none';\n container.appendChild(labelRenderer.domElement);\n\n // 4. 控制器\n controls = new OrbitControls(camera, renderer.domElement);\n controls.enableDamping = !prefersReducedMotion;\n controls.dampingFactor = 0.05;\n\n // 5. 灯光\n const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);\n scene.add(ambientLight);\n\n const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);\n dirLight.position.set(5, 10, 7);\n dirLight.castShadow = true;\n scene.add(dirLight);\n\n // 6. 辅助网格\n const gridHelper = new THREE.GridHelper(20, 20, 0xcbd5e1, 0xe2e8f0);\n scene.add(gridHelper);\n\n // 7. 构建几何模型(通用渲染器:读 model 数据)\n buildModel();\n\n // 8. 监听调整大小\n window.addEventListener('resize', onWindowResize);\n\n // 9. 初始化UI\n initializeLessonText();\n enableDragScroll(document.getElementById('step-container'));\n updateUI();\n // 先渲染一帧,让 CSS2D 长度标签 DOM 挂载,再排版(否则缓存秒开时会漏排)\n labelRenderer.render(scene, camera);\n typesetMath(document.getElementById('canvas-container'));\n buildMeasureToggle();\n\n // 10. 开始动画\n animate();\n }\n\n // ===== 通用几何工具 =====\n function v3(arr) {\n return new THREE.Vector3(arr[0], arr[1], arr[2]);\n }\n\n const POINTS = {}; // name -> THREE.Vector3\n function pt(nameOrCoords) {\n if (Array.isArray(nameOrCoords)) return v3(nameOrCoords);\n return POINTS[nameOrCoords].clone();\n }\n\n function createTubeSegment(p1, p2, radius, material) {\n const direction = new THREE.Vector3().subVectors(p2, p1);\n const length = direction.length();\n const geometry = new THREE.CylinderGeometry(radius, radius, length, 14);\n const mesh = new THREE.Mesh(geometry, material);\n mesh.position.copy(new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5));\n mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.normalize());\n return mesh;\n }\n\n // 返回一条线(实/虚),不自动加入场景,由调用方决定父节点\n function makeLine(p1, p2, color = COLORS.frame, dashed = false, emphasis = false) {\n const material = new THREE.MeshBasicMaterial({ color });\n\n if (dashed) {\n const group = new THREE.Group();\n const direction = new THREE.Vector3().subVectors(p2, p1);\n const length = direction.length();\n const unit = direction.clone().normalize();\n const dashSize = 0.22;\n const gapSize = 0.13;\n const radius = 0.0055;\n\n for (let start = 0; start \u003c length; start += dashSize + gapSize) {\n const end = Math.min(start + dashSize, length);\n const dashStart = p1.clone().add(unit.clone().multiplyScalar(start));\n const dashEnd = p1.clone().add(unit.clone().multiplyScalar(end));\n group.add(createTubeSegment(dashStart, dashEnd, radius, material));\n }\n return group;\n }\n\n const radius = emphasis ? 0.012 : 0.0075;\n return createTubeSegment(p1, p2, radius, material);\n }\n\n function makePlane(points) {\n const geo = new THREE.BufferGeometry();\n const flat = [];\n points.forEach((p) => { flat.push(p.x, p.y, p.z); });\n geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(flat), 3));\n if (points.length === 3) {\n geo.setIndex([0, 1, 2]);\n } else if (points.length === 4) {\n geo.setIndex([0, 1, 2, 0, 2, 3]);\n }\n const mat = new THREE.MeshBasicMaterial({\n color: COLORS.plane,\n side: THREE.DoubleSide,\n transparent: true,\n opacity: 0.2\n });\n return new THREE.Mesh(geo, mat);\n }\n\n function buildModel() {\n // 1. 点坐标\n for (const [name, coords] of Object.entries(model.points)) {\n POINTS[name] = v3(coords);\n }\n\n // 2. 顶点小球 + 标签\n const sphereNames = model.spheres || Object.keys(model.points);\n const dragName = model.draggable ? model.draggable.point : null;\n sphereNames.forEach((name) => {\n const isDrag = name === dragName;\n const geometry = new THREE.SphereGeometry(isDrag ? 0.12 : 0.06, 18, 18);\n const material = new THREE.MeshBasicMaterial({ color: isDrag ? COLORS.emphasis : COLORS.point });\n const sphere = new THREE.Mesh(geometry, material);\n sphere.position.copy(POINTS[name]);\n scene.add(sphere);\n objects[`Point_${name}`] = sphere;\n\n const div = document.createElement('div');\n div.className = 'label';\n div.textContent = name;\n div.style.marginTop = '-1em';\n const label = new CSS2DObject(div);\n label.position.set(0, 0.15, 0);\n sphere.add(label);\n });\n\n // 3. 骨架棱(始终可见)\n const frame = new THREE.Group();\n (model.edges || []).forEach((e) => {\n const line = makeLine(pt(e.a), pt(e.b), colorOf(e.color, COLORS.frame), !!e.dashed);\n frame.add(line);\n if (e.name) objects[e.name] = line;\n });\n scene.add(frame);\n objects['Frame'] = frame;\n\n // 5. 可切换的命名元素(默认隐藏,按步骤 highlight 显示;高亮永远画在最前)\n const elements = model.elements || {};\n for (const [key, el] of Object.entries(elements)) {\n const obj = createElementObj(el);\n if (obj) {\n obj.visible = false;\n scene.add(obj);\n objects[key] = obj;\n }\n }\n\n // 6. 动点拖拽(若声明)\n if (model.draggable) setupDraggable();\n }\n\n // 由元素定义创建 3D 对象(建模与拖拽重建共用)\n function createElementObj(el) {\n let obj = null;\n if (el.type === 'line') {\n obj = makeLine(pt(el.a), pt(el.b), colorOf(el.color, COLORS.emphasis), !!el.dashed, true);\n if (el.depthTest === false) {\n const setNoDepth = (m) => { if (m.material) { m.material.depthTest = false; m.renderOrder = 1; } };\n if (obj.isGroup) obj.children.forEach(setNoDepth); else setNoDepth(obj);\n }\n } else if (el.type === 'plane') {\n obj = makePlane((el.pts || []).map((p) => pt(p)));\n } else if (el.type === 'arrow') {\n const dir = v3(el.dir).normalize();\n const origin = pt(el.origin);\n const head = el.head || [0.2, 0.1];\n obj = new THREE.ArrowHelper(dir, origin, el.length || 1.5, colorOf(el.color, COLORS.normal), head[0], head[1]);\n } else if (el.type === 'sphere') {\n // 球面:translucent 实心球 + 经纬线框,用于外接球/球 O 等\n const center = pt(el.center);\n const radius = el.radius || 1;\n const col = colorOf(el.color, COLORS.plane);\n const group = new THREE.Group();\n const solid = new THREE.Mesh(\n new THREE.SphereGeometry(radius, 40, 32),\n new THREE.MeshBasicMaterial({ color: col, transparent: true, opacity: el.opacity != null ? el.opacity : 0.12, side: THREE.DoubleSide, depthWrite: false })\n );\n const wire = new THREE.LineSegments(\n new THREE.WireframeGeometry(new THREE.SphereGeometry(radius, 24, 16)),\n new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: 0.25 })\n );\n group.add(solid); group.add(wire);\n group.position.copy(center);\n obj = group;\n } else if (el.type === 'axes') {\n obj = new THREE.AxesHelper(el.size || 3);\n } else if (el.type === 'measure') {\n // 线段长度标注:中点朝几何体外侧偏移,贴一个 MathJax 长度标签\n // 注意:CSS2DRenderer 只看 CSS2DObject 自身的 .visible(不看父级),\n // 所以直接返回定位好的 CSS2DObject,highlight 才能正确开关它。\n const mid = pt(el.a).add(pt(el.b)).multiplyScalar(0.5);\n const target = v3(model.target || [0, 0.45, 0]);\n let dir = mid.clone().sub(target);\n if (dir.lengthSq() \u003c 1e-6) dir.set(0, 1, 0);\n dir.normalize().multiplyScalar(el.offset != null ? el.offset : 0.24);\n const div = document.createElement('div');\n div.className = 'measure-label';\n div.innerHTML = '

立体几何解题 → 交互网页 这个技能产出什么 一个可直接用浏览器打开的单页 HTML:左侧题面/答案/分步解析(公式用 MathJax), 右侧是题目对应的 3D 模型(Three.js,可旋转缩放,分步高亮关键元素并切换镜头)。 形态与 一致。 依赖(重要) 计算核心 依赖 sympy 。运行脚本前先确认有一个能 import sympy 的 :跑 。 缺库时的处理(重要) :若 import 报错(sympy 或后续用到的任何库都同理), 先询问用户是否安装 , 得到同意后再帮忙安装( ),或换一个已装该库的解释器; 不要未经询问直接装 。 下文命令里的 均指这个能跑通依赖的解释器。 工作流程 第 1 步:得到 problem spec(三入口归一) 把题目整理成结构化 spec(格式见 ):几何体类型与尺寸、 已知构造点/条件、所求类型与对象、 语言 。 - 文字题目 :直接抽取。 - 图片 :用视觉读图抽取,并 把识别到的题目回显给用户确认 (题面/几何体/尺寸/所求/语言)后再继续。 - 随机出题 :选定几何体与题型,用 kernel 随机参数求解,答案不规整就重抽。 输出语言跟随提示词语言 :英文提示 → 英文网页,中文 → 中文。spec 里记下 。 第 2 步:用 kernel 精确计算(不要心算) 按 的建系约定与解法配方,调用 : 得到精确坐标、关键向量、法向…

+ (el.label != null ? el.label : '') + '

立体几何解题 → 交互网页 这个技能产出什么 一个可直接用浏览器打开的单页 HTML:左侧题面/答案/分步解析(公式用 MathJax), 右侧是题目对应的 3D 模型(Three.js,可旋转缩放,分步高亮关键元素并切换镜头)。 形态与 一致。 依赖(重要) 计算核心 依赖 sympy 。运行脚本前先确认有一个能 import sympy 的 :跑 。 缺库时的处理(重要) :若 import 报错(sympy 或后续用到的任何库都同理), 先询问用户是否安装 , 得到同意后再帮忙安装( ),或换一个已装该库的解释器; 不要未经询问直接装 。 下文命令里的 均指这个能跑通依赖的解释器。 工作流程 第 1 步:得到 problem spec(三入口归一) 把题目整理成结构化 spec(格式见 ):几何体类型与尺寸、 已知构造点/条件、所求类型与对象、 语言 。 - 文字题目 :直接抽取。 - 图片 :用视觉读图抽取,并 把识别到的题目回显给用户确认 (题面/几何体/尺寸/所求/语言)后再继续。 - 随机出题 :选定几何体与题型,用 kernel 随机参数求解,答案不规整就重抽。 输出语言跟随提示词语言 :英文提示 → 英文网页,中文 → 中文。spec 里记下 。 第 2 步:用 kernel 精确计算(不要心算) 按 的建系约定与解法配方,调用 : 得到精确坐标、关键向量、法向…

;\n obj = new CSS2DObject(div);\n obj.position.copy(mid.add(dir));\n }\n return obj;\n }\n\n // ===== 动点拖拽 + 实时数值 =====\n const MATH = {}; // name -> 数学坐标数组(draggable 用)\n function setupDraggable() {\n const dg = model.draggable;\n for (const [k, v] of Object.entries(model.mathPoints || {})) MATH[k] = v.slice();\n\n const moving = new Set([dg.point, ...(dg.dependent || []).map((d) => d.name)]);\n // 哪些元素引用了移动点 -> 拖拽时重建\n const refsMoving = (el) => {\n const names = [el.a, el.b, el.origin, ...(el.pts || [])].filter(Boolean);\n return names.some((n) => moving.has(n));\n };\n const depElements = Object.entries(model.elements || {})\n .filter(([, el]) => refsMoving(el)).map(([k]) => k);\n\n const segA = threeFromMath(MATH[dg.along[0]]);\n const segB = threeFromMath(MATH[dg.along[1]]);\n const raycaster = new THREE.Raycaster();\n const pointer = new THREE.Vector2();\n let dragging = false;\n\n function setParam(t) {\n t = Math.max(0, Math.min(1, t));\n // 数学坐标:动点沿 along 线性插值\n const a = MATH[dg.along[0]], b = MATH[dg.along[1]];\n MATH[dg.point] = [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]), a[2] + t * (b[2] - a[2])];\n // 依赖点(目前支持 midpoint)\n (dg.dependent || []).forEach((d) => {\n if (d.kind === 'midpoint') {\n const p = MATH[d.of[0]], q = MATH[d.of[1]];\n MATH[d.name] = [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2, (p[2] + q[2]) / 2];\n }\n });\n // 更新 three 坐标 + 小球位置\n moving.forEach((n) => {\n POINTS[n] = threeFromMath(MATH[n]);\n if (objects[`Point_${n}`]) objects[`Point_${n}`].position.copy(POINTS[n]);\n });\n // 重建依赖元素(保留可见性)\n depElements.forEach((key) => {\n const old = objects[key];\n const vis = old ? old.visible : false;\n if (old) scene.remove(old);\n const obj = createElementObj(model.elements[key]);\n if (obj) { obj.visible = vis; scene.add(obj); objects[key] = obj; }\n });\n updateReadouts(t);\n }\n\n function pointerNDC(e) {\n const r = renderer.domElement.getBoundingClientRect();\n pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;\n pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;\n }\n\n renderer.domElement.addEventListener('pointerdown', (e) => {\n pointerNDC(e);\n raycaster.setFromCamera(pointer, camera);\n const hit = raycaster.intersectObject(objects[`Point_${dg.point}`], true);\n if (hit.length) {\n dragging = true;\n controls.enabled = false;\n renderer.domElement.style.cursor = 'grabbing';\n }\n });\n window.addEventListener('pointermove', (e) => {\n if (!dragging) return;\n pointerNDC(e);\n raycaster.setFromCamera(pointer, camera);\n // 射线到约束线段的最近点 -> 求参数 t\n const closest = new THREE.Vector3();\n raycaster.ray.distanceSqToSegment(segA, segB, null, closest);\n const seg = new THREE.Vector3().subVectors(segB, segA);\n const t = new THREE.Vector3().subVectors(closest, segA).dot(seg) / seg.lengthSq();\n setParam(t);\n });\n window.addEventListener('pointerup', () => {\n dragging = false;\n controls.enabled = true;\n renderer.domElement.style.cursor = '';\n });\n\n // 初始:放到题目设定位置\n setParam(dg.t != null ? dg.t : 0.5);\n buildReadoutPanel();\n setParam(dg.t != null ? dg.t : 0.5);\n }\n\n // ---- 实时数值(数学坐标下计算真实几何量)----\n function mv(name) { return MATH[name]; }\n function vsub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }\n function vdot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }\n 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]]; }\n function vnorm(a) { return Math.sqrt(vdot(a, a)); }\n\n function computeReadout(r) {\n if (r.type === 'volume_tetra') {\n const [P0, P1, P2, P3] = r.pts.map(mv);\n return Math.abs(vdot(vsub(P1, P0), vcross(vsub(P2, P0), vsub(P3, P0)))) / 6;\n }\n if (r.type === 'length') {\n return vnorm(vsub(mv(r.pts[1]), mv(r.pts[0])));\n }\n if (r.type === 'line_plane_angle_sin') {\n const v = vsub(mv(r.line[1]), mv(r.line[0]));\n const n = vcross(vsub(mv(r.plane[1]), mv(r.plane[0])), vsub(mv(r.plane[2]), mv(r.plane[0])));\n return Math.abs(vdot(v, n)) / (vnorm(v) * vnorm(n));\n }\n return null;\n }\n\n function buildReadoutPanel() {\n if (!(model.draggable.readouts || []).length) return;\n if (document.getElementById('readout-panel')) return;\n const panel = document.createElement('div');\n panel.id = 'readout-panel';\n panel.className = 'readout-panel';\n document.getElementById('canvas-container').appendChild(panel);\n }\n\n function updateReadouts(t) {\n const panel = document.getElementById('readout-panel');\n if (!panel) return;\n const dg = model.draggable;\n const atStd = dg.t != null && Math.abs(t - dg.t) \u003c 0.01;\n const rows = (dg.readouts || []).map((r) => {\n const val = computeReadout(r);\n const num = (val == null) ? '—' : (Math.round(val * 1000) / 1000);\n return `\u003cdiv class=\"readout-row\">\u003cspan>${r.label}\u003c/span>\u003cb>${num}\u003c/b>\u003c/div>`;\n });\n const hint = (dg.standardLabel || '题目设定位') + (atStd ? ' ✓' : '');\n panel.innerHTML =\n `\u003cdiv class=\"readout-title\">拖动点 ${dg.point} 探索 · ${hint}\u003c/div>` + rows.join('');\n }\n\n // 长度标注总开关(仅当存在 measure 元素时出现)\n function buildMeasureToggle() {\n const elements = model.elements || {};\n const hasMeasure = Object.values(elements).some((el) => el.type === 'measure');\n if (!hasMeasure) return;\n const btn = document.createElement('button');\n btn.id = 'measure-toggle';\n btn.type = 'button';\n const sync = () => {\n btn.textContent = measuresOn ? ui.measureToggleOn : ui.measureToggleOff;\n btn.classList.toggle('is-on', measuresOn);\n btn.setAttribute('aria-pressed', String(measuresOn));\n };\n sync();\n btn.addEventListener('click', () => {\n measuresOn = !measuresOn;\n sync();\n updateSceneState();\n });\n document.getElementById('canvas-container').appendChild(btn);\n }\n\n function updateUI() {\n // 更新文本\n const stepData = steps[currentStep];\n document.getElementById('step-indicator').textContent = ui.stepTemplate\n .replace('{current}', currentStep + 1)\n .replace('{total}', steps.length);\n\n const container = document.getElementById('step-container');\n container.innerHTML = `\n \u003cdiv class=\"step-title\">${stepData.title}\u003c/div>\n \u003cdiv class=\"step-content\">${stepData.content}\u003c/div>\n `;\n container.scrollTop = 0;\n\n // 重新渲染 MathJax\n typesetMath(document.getElementById('sidebar'));\n if (!prefersReducedMotion) {\n container.getAnimations().forEach((animation) => animation.cancel());\n container.animate([\n { opacity: 0, transform: 'translateY(8px)' },\n { opacity: 1, transform: 'translateY(0)' }\n ], {\n duration: 280,\n easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)'\n });\n } else {\n container.classList.remove('is-changing');\n }\n\n // 更新按钮状态\n document.getElementById('prev-btn').disabled = currentStep === 0;\n document.getElementById('next-btn').textContent = currentStep === steps.length - 1 ? ui.finish : ui.next;\n document.getElementById('next-btn').setAttribute(\n 'aria-label',\n currentStep === steps.length - 1\n ? ui.finishAria\n : ui.nextAriaTemplate.replace('{step}', currentStep + 2)\n );\n document.getElementById('prev-btn').setAttribute(\n 'aria-label',\n currentStep === 0\n ? ui.firstStepAria\n : ui.previousAriaTemplate.replace('{step}', currentStep)\n );\n\n // 更新 3D 场景状态\n updateSceneState();\n }\n\n function updateSceneState() {\n const data = steps[currentStep];\n\n // 1. 先隐藏所有可切换的命名元素\n const elements = model.elements || {};\n for (const key of Object.keys(elements)) {\n if (objects[key]) objects[key].visible = false;\n }\n\n // 2. 按当前步骤的 highlight 绝对显示(列出该步需要可见的元素)\n if (Array.isArray(data.highlight)) {\n data.highlight.forEach((key) => {\n if (!objects[key]) return;\n const el = elements[key];\n if (el && el.type === 'measure' && !measuresOn) return; // 长度标注总开关:关时不显示\n objects[key].visible = true;\n });\n }\n\n // 3. 移动相机 (平滑过渡)\n if (data.cameraPos) {\n const targetPos = new THREE.Vector3(data.cameraPos.x, data.cameraPos.y, data.cameraPos.z);\n if (prefersReducedMotion) {\n camera.position.copy(targetPos);\n controls.target.copy(cameraTargetPoint);\n controls.update();\n } else {\n targetCameraPosition = targetPos;\n }\n }\n\n // 本步新显示的长度标签需排版:先渲染一帧让其 display 生效(MathJax 跳过 display:none),再排版\n labelRenderer.render(scene, camera);\n typesetMath(document.getElementById('canvas-container'));\n }\n\n function onWindowResize() {\n const container = document.getElementById('canvas-container');\n camera.aspect = container.clientWidth / container.clientHeight;\n camera.updateProjectionMatrix();\n renderer.setSize(container.clientWidth, container.clientHeight);\n labelRenderer.setSize(container.clientWidth, container.clientHeight);\n\n // 面板宽度变化后,重新评估公式是否需要常驻横向滚动条\n markScrollableMath(document.getElementById('sidebar'));\n }\n\n function animate() {\n requestAnimationFrame(animate);\n if (targetCameraPosition) {\n camera.position.lerp(targetCameraPosition, 0.07);\n controls.target.lerp(cameraTargetPoint, 0.07);\n\n if (camera.position.distanceTo(targetCameraPosition) \u003c 0.015) {\n camera.position.copy(targetCameraPosition);\n controls.target.copy(cameraTargetPoint);\n targetCameraPosition = null;\n }\n }\n controls.update();\n renderer.render(scene, camera);\n labelRenderer.render(scene, camera);\n }\n\n // 事件绑定\n document.getElementById('prev-btn').addEventListener('click', () => {\n if (currentStep > 0) {\n goToStep(currentStep - 1);\n }\n });\n\n document.getElementById('next-btn').addEventListener('click', () => {\n if (currentStep \u003c steps.length - 1) {\n goToStep(currentStep + 1);\n }\n });\n\n // 启动\n init();\n \u003c/script>\n\u003c/body>\n\u003c/html>\n","content_type":"text/html; charset=utf-8","language":"markup","size":51133,"content_sha256":"cfd31778bbd7943bf59c280af39e6b3971d6f7ccc15693d60e345a22d0f50a43"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"立体几何解题 → 交互网页","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"这个技能产出什么","type":"text"}]},{"type":"paragraph","content":[{"text":"一个可直接用浏览器打开的单页 HTML:左侧题面/答案/分步解析(公式用 MathJax), 右侧是题目对应的 3D 模型(Three.js,可旋转缩放,分步高亮关键元素并切换镜头)。 形态与 ","type":"text"},{"text":"template/lesson.html","type":"text","marks":[{"type":"code_inline"}]},{"text":" 一致。","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"依赖(重要)","type":"text"}]},{"type":"paragraph","content":[{"text":"计算核心 ","type":"text"},{"text":"lib/geometry_kernel.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" 依赖 ","type":"text"},{"text":"sympy","type":"text","marks":[{"type":"strong"}]},{"text":"。运行脚本前先确认有一个能 import sympy 的 ","type":"text"},{"text":"python3","type":"text","marks":[{"type":"code_inline"}]},{"text":":跑 ","type":"text"},{"text":"python3 -c \"import sympy\"","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"}]},{"type":"paragraph","content":[{"text":"缺库时的处理(重要)","type":"text","marks":[{"type":"strong"}]},{"text":":若 import 报错(sympy 或后续用到的任何库都同理),","type":"text"},{"text":"先询问用户是否安装","type":"text","marks":[{"type":"strong"}]},{"text":", 得到同意后再帮忙安装(","type":"text"},{"text":"python3 -m pip install \u003c库名>","type":"text","marks":[{"type":"code_inline"}]},{"text":"),或换一个已装该库的解释器;","type":"text"},{"text":"不要未经询问直接装","type":"text","marks":[{"type":"strong"}]},{"text":"。 下文命令里的 ","type":"text"},{"text":"python3","type":"text","marks":[{"type":"code_inline"}]},{"text":" 均指这个能跑通依赖的解释器。","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"工作流程","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"第 1 步:得到 problem spec(三入口归一)","type":"text"}]},{"type":"paragraph","content":[{"text":"把题目整理成结构化 spec(格式见 ","type":"text"},{"text":"references/problem-schema.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"):几何体类型与尺寸、 已知构造点/条件、所求类型与对象、","type":"text"},{"text":"语言","type":"text","marks":[{"type":"strong"}]},{"text":"。","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"文字题目","type":"text","marks":[{"type":"strong"}]},{"text":":直接抽取。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"图片","type":"text","marks":[{"type":"strong"}]},{"text":":用视觉读图抽取,并","type":"text"},{"text":"把识别到的题目回显给用户确认","type":"text","marks":[{"type":"strong"}]},{"text":"(题面/几何体/尺寸/所求/语言)后再继续。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"随机出题","type":"text","marks":[{"type":"strong"}]},{"text":":选定几何体与题型,用 kernel 随机参数求解,答案不规整就重抽。","type":"text"}]}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"输出语言跟随提示词语言","type":"text","marks":[{"type":"strong"}]},{"text":":英文提示 → 英文网页,中文 → 中文。spec 里记下 ","type":"text"},{"text":"language","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"第 2 步:用 kernel 精确计算(不要心算)","type":"text"}]},{"type":"paragraph","content":[{"text":"按 ","type":"text"},{"text":"references/conventions.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的建系约定与解法配方,调用 ","type":"text"},{"text":"lib/geometry_kernel.py","type":"text","marks":[{"type":"code_inline"}]},{"text":": 得到精确坐标、关键向量、法向量、最终答案,以及各步骤要展示的中间量(均为 LaTeX 字符串)。 顶点的 three.js 坐标用 ","type":"text"},{"text":"kernel.to_three(points, scale)","type":"text","marks":[{"type":"code_inline"}]},{"text":" 得到。","type":"text"}]},{"type":"paragraph","content":[{"text":"可先在命令行跑 kernel 验证答案,例如:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 lib/geometry_kernel.py # 内置样例自检","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"第 3 步:组装 lesson data 并注入模板","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"📍 ","type":"text"},{"text":"输出位置(重要)","type":"text","marks":[{"type":"strong"}]},{"text":":成品 HTML 一律写到","type":"text"},{"text":"用户当前工作目录(","type":"text","marks":[{"type":"strong"}]},{"text":"Path.cwd()","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":")","type":"text","marks":[{"type":"strong"}]},{"text":",除非用户显式指定路径。 ","type":"text"},{"text":"绝不要","type":"text","marks":[{"type":"strong"}]},{"text":"写进技能自身目录(","type":"text"},{"text":"skills/edu-solid-geometry/output/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等)——那是技能内部的开发样例目录。 临时构建脚本也放到 cwd 或临时目录(如 ","type":"text"},{"text":"/tmp","type":"text","marks":[{"type":"code_inline"}]},{"text":"),用完可删。","type":"text"}]}]},{"type":"paragraph","content":[{"text":"写一个","type":"text"},{"text":"临时构建脚本","type":"text","marks":[{"type":"strong"}]},{"text":",导入 kernel、bodies、generate,拼出 ","type":"text"},{"text":"lesson","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"steps","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"model","type":"text","marks":[{"type":"code_inline"}]},{"text":" 数据 (schema 见 ","type":"text"},{"text":"references/problem-schema.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"),再调用 ","type":"text"},{"text":"generate.render_html(data, out)","type":"text","marks":[{"type":"code_inline"}]},{"text":" 注入模板产出 HTML。 ","type":"text"},{"text":"out","type":"text","marks":[{"type":"code_inline"}]},{"text":" 用 ","type":"text"},{"text":"cwd 下的绝对路径","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"python"},"content":[{"text":"from pathlib import Path\nout = Path.cwd() / \"solution-\u003c题目简述>.html\" # 落在用户当前目录,而非技能目录\ngenerate.render_html(data, out)","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"steps[*].content","type":"text","marks":[{"type":"code_inline"}]},{"text":" 里的所有数值","type":"text"},{"text":"直接引用 kernel 的计算结果","type":"text","marks":[{"type":"strong"}]},{"text":",模型只负责组织讲解文字(按目标语言书写)。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"model.points","type":"text","marks":[{"type":"code_inline"}]},{"text":" 用 ","type":"text"},{"text":"kernel.to_three(...)","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的结果;","type":"text"},{"text":"model.spheres","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"edges","type":"text","marks":[{"type":"code_inline"}]},{"text":" 用 ","type":"text"},{"text":"lib/bodies.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的拓扑 (","type":"text"},{"text":"quad_pyramid","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"tri_pyramid","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"cuboid","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"cube","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"prism","type":"text","marks":[{"type":"code_inline"}]},{"text":"),罕见几何体可手写 edges。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"每步配 ","type":"text"},{"text":"highlight","type":"text","marks":[{"type":"code_inline"}]},{"text":"(该步可见元素的绝对集合)与 ","type":"text"},{"text":"cameraPos","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"题面给出线段长度时","type":"text","marks":[{"type":"strong"}]},{"text":":为对应棱加 ","type":"text"},{"text":"measure","type":"text","marks":[{"type":"code_inline"}]},{"text":" 元素(","type":"text"},{"text":"label","type":"text","marks":[{"type":"code_inline"}]},{"text":" 用 LaTeX,如 ","type":"text"},{"text":"2\\sqrt{2}","type":"text","marks":[{"type":"code_inline"}]},{"text":"), 并把它放进\"建系/列已知条件\"那步的 ","type":"text"},{"text":"highlight","type":"text","marks":[{"type":"code_inline"}]},{"text":",在 3D 图中点处标出长度(见 problem-schema)。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"英文输出时填 ","type":"text"},{"text":"lesson.ui","type":"text","marks":[{"type":"code_inline"}]},{"text":" 英文文案并设 ","type":"text"},{"text":"lesson.language=\"en\"","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"可直接参考的范例","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"},{"text":"scripts/generate.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" 里的 ","type":"text"},{"text":"build_data()","type":"text","marks":[{"type":"code_inline"}]},{"text":"(正四棱锥·线面角)、 ","type":"text"},{"text":"build_cube_data()","type":"text","marks":[{"type":"code_inline"}]},{"text":"(正方体·线面角)、","type":"text"},{"text":"build_box_volume_data()","type":"text","marks":[{"type":"code_inline"}]},{"text":"(长方体·体积)都是完整范本,照着改即可。","type":"text"}]},{"type":"paragraph","content":[{"text":"generate.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" 可直接出已注册的题;","type":"text"},{"text":"不传路径时默认写到当前工作目录(cwd)","type":"text","marks":[{"type":"strong"}]},{"text":",也可显式给 cwd 下的文件名 (用技能目录里的 ","type":"text"},{"text":"scripts/generate.py","type":"text","marks":[{"type":"code_inline"}]},{"text":",输出落在 cwd):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 \u003c技能目录>/scripts/generate.py cube ./cube.html\npython3 \u003c技能目录>/scripts/generate.py box ./box.html","type":"text"}]},{"type":"paragraph","content":[{"text":"随机出题","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"},{"text":"generate.py random \u003cseed> [输出.html]","type":"text","marks":[{"type":"code_inline"}]},{"text":",内部用 ","type":"text"},{"text":"kernel.is_clean(...)","type":"text","marks":[{"type":"code_inline"}]},{"text":" 判答案规整、不过重抽:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 \u003c技能目录>/scripts/generate.py random 7 ./random.html # 不给路径则默认 ./random.html(cwd)","type":"text"}]},{"type":"paragraph","content":[{"text":"扩展随机题型时沿用\"随机参数 → 求解 → is_clean 不过就重抽\"。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"第 4 步:自检(对应正确性方案)","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"kernel 答案 == 答案卡 ","type":"text"},{"text":"answerValue","type":"text","marks":[{"type":"code_inline"}]},{"text":" == 末步骤展示的最终值(generate.py 已有断言示例)。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"3D 顶点坐标来自 ","type":"text"},{"text":"kernel.to_three","type":"text","marks":[{"type":"code_inline"}]},{"text":"(与解题同源)。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"起本地静态服务(服务","type":"text"},{"text":"输出文件所在目录","type":"text","marks":[{"type":"strong"}]},{"text":",即 cwd)用预览检查:无控制台报错、公式渲染正常、分步高亮/镜头符合预期。 (技能仓库内开发时可用 ","type":"text"},{"text":".claude/launch.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的 ","type":"text"},{"text":"geom-preview","type":"text","marks":[{"type":"code_inline"}]},{"text":";在别处运行就对 cwd 起一个临时静态服务。)","type":"text"}]}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"⚠️ ","type":"text"},{"text":"必须关闭你开过的端口/服务","type":"text","marks":[{"type":"strong"}]},{"text":":预览检查一结束就立即停掉本地服务,","type":"text"},{"text":"绝不留下占用端口的进程","type":"text","marks":[{"type":"strong"}]},{"text":"。","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"用 preview 工具开的:检查完马上 ","type":"text"},{"text":"preview_stop","type":"text","marks":[{"type":"code_inline"}]},{"text":"(传对应 serverId)。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"直接起的 ","type":"text"},{"text":"http.server","type":"text","marks":[{"type":"code_inline"}]},{"text":":用完 ","type":"text"},{"text":"kill","type":"text","marks":[{"type":"code_inline"}]},{"text":" 掉,或核对 ","type":"text"},{"text":"lsof -nP -iTCP:\u003cport> -sTCP:LISTEN","type":"text","marks":[{"type":"code_inline"}]},{"text":" 确认已释放。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"交付前确认端口已释放,再告诉用户结果。开了不关 = 未完成自检。","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"第 5 步:交付","type":"text"}]},{"type":"paragraph","content":[{"text":"成品写在","type":"text"},{"text":"用户当前工作目录(cwd)","type":"text","marks":[{"type":"strong"}]},{"text":",命名形如 ","type":"text"},{"text":"solution-\u003c题目简述>.html","type":"text","marks":[{"type":"code_inline"}]},{"text":",把(cwd 下的)路径告诉用户,可直接浏览器打开。 交付前确认:","type":"text"},{"text":"(1)","type":"text","marks":[{"type":"strong"}]},{"text":" 成品在 cwd、不在技能目录;","type":"text"},{"text":"(2)","type":"text","marks":[{"type":"strong"}]},{"text":" 没有遗留任何由本次预览开启的本地服务/端口。","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"扩展","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"加题型","type":"text","marks":[{"type":"strong"}]},{"text":":在 ","type":"text"},{"text":"geometry_kernel.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" 加求解函数(见 conventions 配方表),在 ","type":"text"},{"text":"generate.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" 加一个 ","type":"text"},{"text":"build_*","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"加几何体","type":"text","marks":[{"type":"strong"}]},{"text":":在 ","type":"text"},{"text":"geometry_kernel.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" 加坐标构建函数,在 ","type":"text"},{"text":"bodies.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" 加棱拓扑。","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"目录","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"template/lesson.html","type":"text","marks":[{"type":"code_inline"}]},{"text":" — 数据驱动模板(通用 3D 渲染器 + 数据岛 ","type":"text"},{"text":"__LESSON_DATA__","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"lib/geometry_kernel.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — sympy 精确计算核心","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"lib/bodies.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — 几何体棱拓扑库","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/generate.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — 注入模板 + 范例构建函数","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/problem-schema.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — 数据格式","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/conventions.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — 建系约定、解法配方、自检","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"edu-solid-geometry","author":"@skillopedia","source":{"stars":1734,"repo_name":"edulab","origin_url":"https://github.com/wy51ai/edulab/blob/HEAD/skills/edu-solid-geometry/SKILL.md","repo_owner":"wy51ai","body_sha256":"d3ac945d78ee0bc1108f60d853668a3bd2256c63771afd275b201c779252db71","cluster_key":"5f9b6a9bb5ba7315009991e1abf9ff67412d8e770ba33a6eb349ddc05535a384","clean_bundle":{"format":"clean-skill-bundle-v1","source":"wy51ai/edulab/skills/edu-solid-geometry/SKILL.md","text_bytes":89325,"source_commit":"805fdc5dd6bdf942290a7791924bf0ee08f26632","skills_sh_rank":15549,"attachment_count":6,"text_attachments":6,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/edu-solid-geometry/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"education-research","category_label":"Education"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"education-research","import_tag":"skills-sh-top20k-2026-11","description":"把一道立体几何题解成一个自包含的交互教学网页:左侧 MathJax 分步解析, 右侧 Three.js 可交互 3D 模型(分步高亮 + 镜头切换)。支持三种入口——给定文字题目、 随机出题、上传题目图片识别后解题。覆盖正方体/长方体、棱锥/棱柱、圆柱/圆锥上的线面角、 二面角、异面直线夹角、点到平面距离、体积等题型,统一用\"建系+向量法\",并由 sympy 精确 计算驱动(答案、3D 坐标、步骤数值同源一致)。其他 agent 也可调用本技能生成此类网页。 触发词:立体几何, 线面角, 二面角, 异面直线, 点到平面距离, 正四棱锥, 正方体求角, 解这道几何题, 随机出一道立体几何题, 这张图里的立体几何题; solid geometry, line-plane angle, dihedral angle, angle between skew lines, distance to plane, interactive geometry solution page."}},"renderedAt":1793665932519}

立体几何解题 → 交互网页 这个技能产出什么 一个可直接用浏览器打开的单页 HTML:左侧题面/答案/分步解析(公式用 MathJax), 右侧是题目对应的 3D 模型(Three.js,可旋转缩放,分步高亮关键元素并切换镜头)。 形态与 一致。 依赖(重要) 计算核心 依赖 sympy 。运行脚本前先确认有一个能 import sympy 的 :跑 。 缺库时的处理(重要) :若 import 报错(sympy 或后续用到的任何库都同理), 先询问用户是否安装 , 得到同意后再帮忙安装( ),或换一个已装该库的解释器; 不要未经询问直接装 。 下文命令里的 均指这个能跑通依赖的解释器。 工作流程 第 1 步:得到 problem spec(三入口归一) 把题目整理成结构化 spec(格式见 ):几何体类型与尺寸、 已知构造点/条件、所求类型与对象、 语言 。 - 文字题目 :直接抽取。 - 图片 :用视觉读图抽取,并 把识别到的题目回显给用户确认 (题面/几何体/尺寸/所求/语言)后再继续。 - 随机出题 :选定几何体与题型,用 kernel 随机参数求解,答案不规整就重抽。 输出语言跟随提示词语言 :英文提示 → 英文网页,中文 → 中文。spec 里记下 。 第 2 步:用 kernel 精确计算(不要心算) 按 的建系约定与解法配方,调用 : 得到精确坐标、关键向量、法向…