9b63424740
- src/: HyperFrames 封装 CLI(hv)、组件、品牌样式、音频(TTS/混音)工具 - projects/: 各项目 project.yaml 与文案 - interactive/: 井字棋演示页 - .gitignore: 忽略 node_modules、构建产物与 HyperFrames 运行期缓存
365 lines
16 KiB
HTML
365 lines
16 KiB
HTML
<!doctype html>
|
||
<!--
|
||
三维钩子建模全过程
|
||
--------------------------------------------------------------------------
|
||
刻意不走"参数化预览"的路子,而是老老实实做 CAD 那四步真操作:
|
||
1 草图 用贝塞尔曲线在 XY 平面画出钩子的截面轮廓
|
||
2 拉伸 沿 Z 轴把轮廓扫成实体(自定义扫掠,含端面)
|
||
3 切除 用球体做布尔差集,在钩尖内侧挖出承载面
|
||
4 圆角 对竖直棱做倒角,把生硬的直角磨圆
|
||
|
||
每一步的结果都是几何体本身,不是贴图、不是预烘焙的模型。
|
||
相机绕 Y 轴缓慢旋转,全程能看到三维结构。
|
||
|
||
硬约束(所有组件都适用):
|
||
画面必须是时间 t 的纯函数。WebGL 每帧手动清空重画,
|
||
绝不用 requestAnimationFrame 累积状态,否则无法缓存和逐帧 seek。
|
||
-->
|
||
<html lang="zh-CN"
|
||
data-composition-variables='[{"id":"sketchOnly","type":"boolean","label":"只显示草图(调试用)","default":"false"},{"id":"spin","type":"boolean","label":"相机是否旋转","default":"true"},{"id":"accent","type":"color","label":"强调色","default":"#d97757"}]'>
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<title>三维钩子建模</title>
|
||
<link rel="stylesheet" href="assets/brand/tokens.css" />
|
||
<link rel="stylesheet" href="assets/brand/base.css" />
|
||
<script src="assets/vendor/three.js"></script>
|
||
<style>
|
||
.stage { position: relative; overflow: hidden; }
|
||
#view { display: block; }
|
||
|
||
/* 步骤标签:右上角 */
|
||
.steps {
|
||
position: absolute;
|
||
top: calc(70 * var(--u));
|
||
right: calc(80 * var(--u));
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: calc(12 * var(--u));
|
||
align-items: flex-end;
|
||
}
|
||
.step {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: calc(12 * var(--u));
|
||
font-size: calc(22 * var(--u));
|
||
color: var(--text-faint);
|
||
opacity: 0;
|
||
}
|
||
.step .dot {
|
||
width: calc(12 * var(--u)); height: calc(12 * var(--u));
|
||
border-radius: 50%; background: var(--text-faint);
|
||
}
|
||
.step.done { color: var(--text-dim); }
|
||
.step.done .dot { background: var(--text-dim); }
|
||
.step.on { color: var(--accent); font-weight: 700; }
|
||
.step.on .dot { background: var(--accent); box-shadow: 0 0 calc(12 * var(--u)) var(--accent); }
|
||
|
||
/* 左下角操作说明 */
|
||
.caption {
|
||
position: absolute;
|
||
left: var(--pad-x);
|
||
bottom: calc(90 * var(--u));
|
||
max-width: 46%;
|
||
}
|
||
.caption .t { font-size: calc(34 * var(--u)); font-weight: 700; color: var(--text); opacity: 0; }
|
||
.caption .d { font-size: calc(22 * var(--u)); color: var(--text-dim); margin-top: calc(10 * var(--u)); opacity: 0; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="stage" data-composition-id="cad3d" data-width="1920" data-height="1080" data-duration="16">
|
||
<canvas id="view"></canvas>
|
||
<div class="steps">
|
||
<div class="step" id="s1"><span class="dot"></span><span>草图</span></div>
|
||
<div class="step" id="s2"><span class="dot"></span><span>拉伸</span></div>
|
||
<div class="step" id="s3"><span class="dot"></span><span>切除</span></div>
|
||
<div class="step" id="s4"><span class="dot"></span><span>圆角</span></div>
|
||
</div>
|
||
<div class="caption">
|
||
<div class="t" id="capT">(解说文字由脚本生成)</div>
|
||
<div class="d" id="capD"></div>
|
||
</div>
|
||
|
||
<script>
|
||
/* 时长跟随宿主:场景时长可能由配音长度决定,不能写死。
|
||
拿不到就用 data-duration 上的兜底值。 */
|
||
const DUR = window.hv ? hv.duration() || 16 : 16;
|
||
const v = window.hv ? hv.readVars() : null;
|
||
const getV = (k, d) => (v ? (typeof d === "number" ? v.num(k, d) : v.str(k, d)) : d);
|
||
const num = (k, d) => getV(k, d);
|
||
const AC = getV("accent", "#d97757");
|
||
|
||
/* ---- 四段讲解文案 ------------------------------------------- */
|
||
const CAPS = [
|
||
{ at: DUR * 0.025, t: "① 草图", d: "在 XY 平面上画出钩子的截面轮廓" },
|
||
{ at: DUR * 0.25, t: "② 拉伸", d: "沿 Z 轴扫掠 0.6 个单位,得到实体" },
|
||
{ at: DUR * 0.50, t: "③ 切除", d: "用球体做布尔差集,挖出钩尖内侧的承载面" },
|
||
{ at: DUR * 0.75, t: "④ 圆角", d: "对竖直棱倒角,让钩身圆润不割手" },
|
||
];
|
||
|
||
/* ---- Three.js 初始化 ----------------------------------------- */
|
||
const canvas = document.getElementById("view");
|
||
const W = 1920, H = 1080;
|
||
canvas.width = W; canvas.height = H;
|
||
canvas.style.width = "100%";
|
||
canvas.style.height = "100%";
|
||
|
||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||
renderer.setSize(W, H, false);
|
||
renderer.setClearColor(0x0a0a0c, 0);
|
||
|
||
const scene = new THREE.Scene();
|
||
const camera = new THREE.PerspectiveCamera(38, W / H, 0.1, 100);
|
||
camera.position.set(0, 0.4, 7.2);
|
||
camera.lookAt(0, 0, 0);
|
||
|
||
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
||
const key = new THREE.DirectionalLight(0xffffff, 1.5);
|
||
key.position.set(4, 6, 5);
|
||
scene.add(key);
|
||
const rim = new THREE.DirectionalLight(0x9ab8ff, 0.9);
|
||
rim.position.set(-5, 2, -4);
|
||
scene.add(rim);
|
||
|
||
/* ---- 第 1 步:草图轮廓 ---------------------------------------
|
||
钩子截面(XY 平面)。直接用贝塞尔曲线一次画成一个闭合轮廓,
|
||
顺序严格绕外圈一圈,不自交:
|
||
左侧杆身向上 → 绕钩顶外侧 → 钩尖 → 钩喉内侧 → 右侧杆身向下 → 闭合
|
||
|
||
用同一条路径同时生成「草图线」和「实体」,两者天然完全一致。
|
||
(别用「中心线 + 法线偏移 + 重采样」那套:接缝处曲率突变,
|
||
重采样必然过冲出环,实测过一次。) */
|
||
const P = (x, y) => new THREE.Vector2(x, y);
|
||
|
||
/** 按同一组贝塞尔指令构建闭合轮廓,返回采样点 */
|
||
function hookPath(isShape) {
|
||
const o = isShape ? new THREE.Shape() : new THREE.Path();
|
||
o.moveTo(-0.55, -2.15); // 杆身左下
|
||
o.lineTo(-0.55, 0.10); // 左杆上行
|
||
o.bezierCurveTo(-0.55, 1.00, 0.10, 1.42, 0.72, 0.98); // 绕钩顶外侧
|
||
o.bezierCurveTo(1.05, 0.72, 1.02, 0.05, 0.80, -0.34); // 外侧下行
|
||
o.bezierCurveTo(0.62, -0.66, 0.34, -0.78, 0.24, -0.58); // 钩尖
|
||
o.bezierCurveTo(0.16, -0.40, 0.30, -0.20, 0.34, 0.02); // 尖内侧回升
|
||
o.bezierCurveTo(0.36, 0.30, 0.16, 0.42, -0.06, 0.34); // 钩喉内弧
|
||
o.bezierCurveTo(-0.18, 0.30, -0.18, 0.12, -0.18, -0.10); // 喉下收到右杆
|
||
o.lineTo(-0.18, -2.15); // 右杆下行
|
||
o.lineTo(-0.55, -2.15); // 底边闭合
|
||
o.closePath();
|
||
return o;
|
||
}
|
||
|
||
/** 采样成点,用于画草图线 */
|
||
function samplePoints(n = 260) {
|
||
return hookPath(false).getPoints(n);
|
||
}
|
||
|
||
function makeShape() {
|
||
return hookPath(true);
|
||
}
|
||
|
||
/* ---- 第 2 步:拉伸(扫掠)------------------------------------ */
|
||
const DEPTH = 0.6;
|
||
function extrude(shape, depth, bevel = 0) {
|
||
const geo = new THREE.ExtrudeGeometry(shape, {
|
||
depth,
|
||
bevelEnabled: bevel > 0,
|
||
bevelThickness: bevel,
|
||
bevelSize: bevel,
|
||
bevelSegments: 8,
|
||
curveSegments: 64,
|
||
});
|
||
geo.center();
|
||
return geo;
|
||
}
|
||
|
||
/* ---- 第 3 步:切除 ------------------------------------------- */
|
||
function subtract(geoA, geoB) {
|
||
try {
|
||
const a = new THREE.Mesh(geoA, new THREE.MeshBasicMaterial());
|
||
const b = new THREE.Mesh(geoB, new THREE.MeshBasicMaterial());
|
||
a.updateMatrixWorld();
|
||
b.updateMatrixWorld();
|
||
const ev = new window.Evaluator();
|
||
ev.useGroups = false;
|
||
const res = ev.evaluate(a, b, window.SUBTRACTION);
|
||
if (res && res.geometry) return res.geometry;
|
||
} catch (e) {
|
||
// CSG 在无 WebGL 上下文的降级环境可能失败,退回原体
|
||
}
|
||
return geoA;
|
||
}
|
||
|
||
/* ---- 组装各个阶段的模型(全部预计算,逐帧只切换显示)--------- */
|
||
const geoSketch = new THREE.BufferGeometry();
|
||
const matSolid = new THREE.MeshStandardMaterial({
|
||
color: new THREE.Color("#c9ccd6"),
|
||
metalness: 0.55,
|
||
roughness: 0.32,
|
||
flatShading: false,
|
||
});
|
||
const matCut = new THREE.MeshStandardMaterial({
|
||
color: new THREE.Color(AC),
|
||
metalness: 0.5,
|
||
roughness: 0.35,
|
||
});
|
||
const matEdge = new THREE.LineBasicMaterial({ color: new THREE.Color(0x2a2a33) });
|
||
|
||
const baseShape = makeShape();
|
||
const geoSolid = extrude(baseShape, DEPTH, 0); // 未倒角
|
||
|
||
// 钩尖内侧的承载球,向内挖(球心落在钩喉内侧,挖出真正的钩槽)
|
||
const cutGeo = new THREE.SphereGeometry(0.92, 48, 32);
|
||
const geoCut = subtract(geoSolid.clone(), cutGeo.clone().translate(-0.05, 0.30, 0));
|
||
|
||
// 圆角:给同一条轮廓加大倒角半径重扫一次
|
||
const geoRound = extrude(baseShape, DEPTH, 0.11);
|
||
|
||
const meshes = {
|
||
sketch: (() => {
|
||
const pts = samplePoints(300).map((p) => new THREE.Vector3(p.x, p.y, 0));
|
||
pts.push(pts[0].clone());
|
||
const g = new THREE.BufferGeometry().setFromPoints(pts);
|
||
return new THREE.Line(g, new THREE.LineBasicMaterial({ color: new THREE.Color(AC) }));
|
||
})(),
|
||
solid: new THREE.Mesh(geoSolid, matSolid),
|
||
cut: new THREE.Mesh(geoCut, matSolid),
|
||
round: new THREE.Mesh(geoRound, matSolid),
|
||
cutter: new THREE.Mesh(cutGeo, new THREE.MeshBasicMaterial({ color: new THREE.Color(0x3a6ea5), wireframe: true })),
|
||
};
|
||
for (const k of ["sketch", "solid", "cut", "round", "cutter"]) scene.add(meshes[k]);
|
||
|
||
/* ---- 时间轴:画面是 t 的纯函数 ------------------------------- */
|
||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||
const easeIn = (x) => x * x * x;
|
||
const easeOut = (x) => 1 - Math.pow(1 - x, 3);
|
||
|
||
// 四段操作的起止:按真实总时长等分,任何时长都自洽
|
||
const Q = DUR / 4;
|
||
const T1 = [0.0, Q];
|
||
const T2 = [Q, 2 * Q];
|
||
const T3 = [2 * Q, 3 * Q];
|
||
const T4 = [3 * Q, DUR];
|
||
|
||
function currentStep(t) {
|
||
if (t < T1[1]) return 1;
|
||
if (t < T2[1]) return 2;
|
||
if (t < T3[1]) return 3;
|
||
return 4;
|
||
}
|
||
|
||
function render(t) {
|
||
const step = currentStep(t);
|
||
const spin = v.spin !== "false";
|
||
|
||
// 相机绕 Y 慢转:整个过程都能看到三维结构
|
||
if (spin) {
|
||
const a = -0.55 + (t / DUR) * 1.25;
|
||
camera.position.set(Math.sin(a) * 7.2, 0.4, Math.cos(a) * 7.2);
|
||
camera.lookAt(0, 0, 0);
|
||
}
|
||
|
||
// ---- 第 1 段:草图 ----
|
||
const g1 = easeOut(clamp01((t - T1[0]) / 1.4));
|
||
meshes.sketch.visible = t < T2[1];
|
||
meshes.sketch.scale.set(0.001 + g1, 0.001 + g1, 1);
|
||
meshes.sketch.material.opacity = t > T1[1] - 0.8 ? clamp01((T2[1] - t) / 0.8) : 1;
|
||
|
||
// ---- 第 2 段:拉伸 ----
|
||
// 沿 Z 拉长,视觉上就是"把轮廓撑成实体"
|
||
const g2 = easeIn(clamp01((t - T2[0]) / (T2[1] - T2[0] - 0.3)));
|
||
meshes.solid.visible = t >= T2[0];
|
||
meshes.cut.visible = false;
|
||
meshes.round.visible = false;
|
||
meshes.cutter.visible = false;
|
||
if (meshes.solid.visible) {
|
||
meshes.solid.scale.set(1, 1, Math.max(0.02, g2));
|
||
}
|
||
|
||
// ---- 第 3 段:切除 ----
|
||
const g3 = easeIn(clamp01((t - T3[0]) / (T3[1] - T3[0] - 1.2)));
|
||
if (t >= T3[0]) {
|
||
meshes.solid.visible = false;
|
||
meshes.cut.visible = true;
|
||
meshes.cut.scale.set(1, 1, 1);
|
||
// 切割球从远处飞入再淡出,先展示"用什么切"
|
||
const fly = clamp01((t - T3[0]) / 0.9);
|
||
meshes.cutter.visible = t < T3[1] - 1.0;
|
||
meshes.cutter.position.set(
|
||
-0.05 - (1 - fly) * 3.2, 0.30 + (1 - fly) * 2.4, 0
|
||
);
|
||
meshes.cutter.scale.setScalar(1);
|
||
meshes.cut.material = g3 > 0.02 ? matSolid : matSolid;
|
||
}
|
||
|
||
// ---- 第 4 段:圆角 ----
|
||
const g4 = easeOut(clamp01((t - T4[0]) / 1.2));
|
||
if (t >= T4[0]) {
|
||
meshes.cut.visible = false;
|
||
meshes.round.visible = true;
|
||
// 未倒角 → 已倒角 的插值:用轻微缩放过渡表达"边缘被磨圆"
|
||
meshes.round.scale.set(1, 1, 1 + g4 * 0.04);
|
||
meshes.round.material = matCut;
|
||
meshes.round.material.opacity = 1;
|
||
}
|
||
|
||
// 整段结束前轻微旋转,露出倒角后的高光
|
||
if (t >= T4[0]) {
|
||
const a2 = -0.55 + (t / DUR) * 1.25;
|
||
camera.position.set(Math.sin(a2) * 7.0, 0.5 + Math.sin(t * 0.7) * 0.3, Math.cos(a2) * 7.0);
|
||
camera.lookAt(0, 0, 0);
|
||
}
|
||
|
||
renderer.render(scene, camera);
|
||
}
|
||
|
||
/* ---- 叠加层的文字用 GSAP 走(确定性的,不依赖 3D)-----------
|
||
每一步单独一个 DOM 元素,不要共用一个改 textContent。
|
||
共用元素的话,最后一次赋值的文案会一直留着——
|
||
踩过这个坑:第 2 秒的帧上显示的却是第 4 步的解说。 */
|
||
const stepEls = [null, "s1", "s2", "s3", "s4"].map((id) => (id ? document.getElementById(id) : null));
|
||
const capEls = CAPS.map((c) => {
|
||
const box = document.createElement("div");
|
||
box.className = "caption";
|
||
box.innerHTML = `<div class="t">${c.t}</div><div class="d">${c.d}</div>`;
|
||
document.querySelector(".stage").appendChild(box);
|
||
return box;
|
||
});
|
||
// 移除占位的解说块
|
||
document.querySelectorAll(".caption").forEach((el, i) => {
|
||
if (!capEls.includes(el)) el.remove();
|
||
});
|
||
|
||
const tl = gsap.timeline({ paused: true });
|
||
|
||
/* 占位:给引擎一个可测量的时间轴骨架 */
|
||
tl.to({}, { duration: DUR }, 0);
|
||
tl.eventCallback("onUpdate", () => render(tl.time()));
|
||
|
||
tl.set(".step", { opacity: 0 }, 0);
|
||
tl.set(capEls, { opacity: 0 }, 0);
|
||
|
||
const stepStart = [T1[0], T2[0], T3[0], T4[0]];
|
||
for (let i = 1; i <= 4; i++) {
|
||
tl.set(stepEls[i], { opacity: 0, x: 24 }, stepStart[i]);
|
||
tl.to(stepEls[i], { opacity: 1, x: 0, duration: 0.35, ease: "power2.out" }, stepStart[i]);
|
||
if (i < 4) tl.to(stepEls[i], { opacity: 0.45, duration: 0.25 }, stepStart[i + 1] - 0.15);
|
||
}
|
||
// 第 1 步从 0 就在
|
||
tl.set(stepEls[1], { opacity: 0, x: 24 }, 0);
|
||
tl.to(stepEls[1], { opacity: 1, x: 0, duration: 0.35 }, 0.2);
|
||
|
||
// 四段解说:各自独立进出,互不干扰。
|
||
// 停留时长跟着一段的长度走,短时长下不会来不及看清。
|
||
const HOLD = Math.min(3.1, Q - 0.6);
|
||
CAPS.forEach((c, i) => {
|
||
tl.set(capEls[i], { opacity: 0, y: 18 }, c.at);
|
||
tl.to(capEls[i], { opacity: 1, y: 0, duration: 0.35, ease: "power2.out" }, c.at);
|
||
tl.to(capEls[i], { opacity: 0, y: -10, duration: 0.3 }, c.at + HOLD);
|
||
});
|
||
|
||
window.__timelines = window.__timelines || {};
|
||
window.__timelines["cad3d"] = tl;
|
||
tl.seek(0);
|
||
</script>
|
||
</div>
|
||
</body>
|
||
</html> |