Files
AshuaiXsinfo/src/components/cad3d/cad3d.html
T
xiaoshuai 9b63424740 chore: 初始化仓库,提交 vibevideo 源码
- src/: HyperFrames 封装 CLI(hv)、组件、品牌样式、音频(TTS/混音)工具
- projects/: 各项目 project.yaml 与文案
- interactive/: 井字棋演示页
- .gitignore: 忽略 node_modules、构建产物与 HyperFrames 运行期缓存
2026-10-12 18:06:20 +08:00

365 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>