@sliiu/present-three-bake 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COPYRIGHT +5 -0
- package/LICENSE +661 -0
- package/THIRD-PARTY-NOTICES.md +221 -0
- package/dist/cjs/bake-sp3d.js +176 -0
- package/dist/cjs/camera.js +94 -0
- package/dist/cjs/index.js +47 -0
- package/dist/cjs/light-rig.js +162 -0
- package/dist/cjs/material.js +136 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/path-to-shape.js +331 -0
- package/dist/cjs/presets.js +112 -0
- package/dist/cjs/resolve.js +98 -0
- package/dist/cjs/runtime.js +146 -0
- package/dist/cjs/scene-builder.js +329 -0
- package/dist/cjs/shape-path.js +71 -0
- package/dist/cjs/types.js +2 -0
- package/dist/esm/bake-sp3d.js +140 -0
- package/dist/esm/camera.js +58 -0
- package/dist/esm/index.js +9 -0
- package/dist/esm/light-rig.js +126 -0
- package/dist/esm/material.js +98 -0
- package/dist/esm/path-to-shape.js +295 -0
- package/dist/esm/presets.js +109 -0
- package/dist/esm/resolve.js +91 -0
- package/dist/esm/runtime.js +141 -0
- package/dist/esm/scene-builder.js +290 -0
- package/dist/esm/shape-path.js +64 -0
- package/dist/esm/types.js +1 -0
- package/dist/types/bake-sp3d.d.ts +2 -0
- package/dist/types/camera.d.ts +7 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/light-rig.d.ts +10 -0
- package/dist/types/material.d.ts +17 -0
- package/dist/types/path-to-shape.d.ts +11 -0
- package/dist/types/presets.d.ts +23 -0
- package/dist/types/resolve.d.ts +67 -0
- package/dist/types/runtime.d.ts +22 -0
- package/dist/types/scene-builder.d.ts +35 -0
- package/dist/types/shape-path.d.ts +15 -0
- package/dist/types/types.d.ts +1 -0
- package/package.json +30 -4
- package/README.md +0 -3
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { CAMERA_PRESETS, DEFAULT_BEVEL_PX, DEFAULT_FOV, DEFAULT_LIGHT_RIG, DEFAULT_MATERIAL, FOV_LOCKED_PRESETS, } from "./presets.js";
|
|
2
|
+
export function resolveCamera(cam) {
|
|
3
|
+
let lat = 0;
|
|
4
|
+
let lon = 0;
|
|
5
|
+
let rev = 0;
|
|
6
|
+
let fov = DEFAULT_FOV;
|
|
7
|
+
if (cam?.prst) {
|
|
8
|
+
const p = CAMERA_PRESETS[cam.prst];
|
|
9
|
+
if (p) {
|
|
10
|
+
lat = p.lat;
|
|
11
|
+
lon = p.lon;
|
|
12
|
+
rev = p.rev;
|
|
13
|
+
fov = p.fov;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// 显式 <a:rot> 覆盖角度(preset 与 rot 是覆盖而非叠加)。
|
|
17
|
+
if (cam?.rot) {
|
|
18
|
+
lat = cam.rot.lat;
|
|
19
|
+
lon = cam.rot.lon;
|
|
20
|
+
rev = cam.rot.rev;
|
|
21
|
+
}
|
|
22
|
+
// 显式 @fov 覆盖 fov——但 relaxed 系 preset 无条件用预设窄 fov(PPT 把显式 fov 当 zoom 参考)。
|
|
23
|
+
if (cam?.fov !== undefined && !(cam.prst && FOV_LOCKED_PRESETS.has(cam.prst))) {
|
|
24
|
+
fov = cam.fov;
|
|
25
|
+
}
|
|
26
|
+
return { lat, lon, rev, fov, isOrtho: fov < 1, zoom: cam?.zoom };
|
|
27
|
+
}
|
|
28
|
+
function resolveBevel(b) {
|
|
29
|
+
if (!b)
|
|
30
|
+
return undefined;
|
|
31
|
+
return {
|
|
32
|
+
w: b.w ?? DEFAULT_BEVEL_PX,
|
|
33
|
+
h: b.h ?? DEFAULT_BEVEL_PX,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function resolveSp3D(sp3d) {
|
|
37
|
+
const bevelT = resolveBevel(sp3d?.bevelT);
|
|
38
|
+
const bevelB = resolveBevel(sp3d?.bevelB);
|
|
39
|
+
return {
|
|
40
|
+
extrusionH: sp3d?.extrusionH ?? 0,
|
|
41
|
+
contourW: sp3d?.contourW,
|
|
42
|
+
contourClr: sp3d?.contourClr,
|
|
43
|
+
extrusionClr: sp3d?.extrusionClr,
|
|
44
|
+
prstMaterial: sp3d?.prstMaterial ?? DEFAULT_MATERIAL,
|
|
45
|
+
bevelT,
|
|
46
|
+
bevelB,
|
|
47
|
+
hasBevel: !!(bevelT || bevelB),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function resolveLightRig(lr) {
|
|
51
|
+
return { rig: lr?.rig ?? DEFAULT_LIGHT_RIG, dir: lr?.dir };
|
|
52
|
+
}
|
|
53
|
+
function asVec(v) {
|
|
54
|
+
return "dx" in v ? { x: v.dx, y: v.dy, z: v.dz } : v;
|
|
55
|
+
}
|
|
56
|
+
function normalizeVec(v) {
|
|
57
|
+
const len = Math.hypot(v.x, v.y, v.z);
|
|
58
|
+
if (len < 1e-8)
|
|
59
|
+
return undefined;
|
|
60
|
+
return { x: v.x / len, y: v.y / len, z: v.z / len };
|
|
61
|
+
}
|
|
62
|
+
function nearAxis(v, x, y, z, eps = 0.02) {
|
|
63
|
+
return Math.abs(v.x - x) < eps && Math.abs(v.y - y) < eps && Math.abs(v.z - z) < eps;
|
|
64
|
+
}
|
|
65
|
+
/** identity / 全 0 → undefined,setupCamera 保持 preset 朝向。 */
|
|
66
|
+
export function resolveBackdrop(bd) {
|
|
67
|
+
if (!bd)
|
|
68
|
+
return undefined;
|
|
69
|
+
const n = bd.norm ? normalizeVec(asVec(bd.norm)) : undefined;
|
|
70
|
+
const u = bd.up ? normalizeVec(asVec(bd.up)) : undefined;
|
|
71
|
+
const identityN = !n || nearAxis(n, 0, 0, 1);
|
|
72
|
+
const identityU = !u || nearAxis(u, 0, 1, 0);
|
|
73
|
+
const a = bd.anchor;
|
|
74
|
+
const hasAnchor = !!(a && (Math.abs(a.x) > 1e-4 || Math.abs(a.y) > 1e-4 || Math.abs(a.z) > 1e-4));
|
|
75
|
+
if (identityN && identityU && !hasAnchor)
|
|
76
|
+
return undefined;
|
|
77
|
+
return {
|
|
78
|
+
target: a ?? { x: 0, y: 0, z: 0 },
|
|
79
|
+
forward: identityN ? undefined : n,
|
|
80
|
+
up: identityU ? undefined : u,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function resolveScene3D(info) {
|
|
84
|
+
const scene = info.scene3d;
|
|
85
|
+
return {
|
|
86
|
+
camera: resolveCamera(scene?.camera),
|
|
87
|
+
sp3d: resolveSp3D(info.sp3d),
|
|
88
|
+
lightRig: resolveLightRig(scene?.lightRig),
|
|
89
|
+
backdrop: resolveBackdrop(scene?.backdrop),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// 高层 runtime helper:把 bakeSp3d 接到任意持有 Scene 的离屏光栅消费者
|
|
2
|
+
// (thumbnail=SvgBackend / snapshot+pdf=CanvasBackend)上。bake 完成后通过 `applyBaked`
|
|
3
|
+
// 回调把结果交给 caller 写入**副通道**(meta3D.bakedSrc 等),由那些后端读取显示。
|
|
4
|
+
// player/editor 的实时视图不走这里,改用 packages/renderer 的 WebGLShapeBackend。
|
|
5
|
+
//
|
|
6
|
+
// 触发条件见 needs3DRender:相机 rot 非平凡 / extrusionH>0 / 声明了 bevel。
|
|
7
|
+
// 性能:bake 是 OffscreenCanvas + WebGLRenderer 的一次性渲染,per-shape 30-100 ms。
|
|
8
|
+
import { bakeSp3d } from "./bake-sp3d.js";
|
|
9
|
+
import { shape3DInfo, shape3DPathD, shapeSupports3D } from "./shape-path.js";
|
|
10
|
+
/**
|
|
11
|
+
* 该 3D 信息是否需要 3D 渲染(实时 or 烘焙)。parser 现在对 `<a:bevelT/>` 留空 w/h,
|
|
12
|
+
* 所以用「是否声明了 bevel 元素」判断,而非 h>0。
|
|
13
|
+
*/
|
|
14
|
+
export function needs3DRender(meta) {
|
|
15
|
+
if (!meta)
|
|
16
|
+
return false;
|
|
17
|
+
const rot = meta.scene3d?.camera?.rot;
|
|
18
|
+
const hasRot = !!rot && Math.abs(rot.lat) + Math.abs(rot.lon) + Math.abs(rot.rev) > 0.01;
|
|
19
|
+
const ext = meta.sp3d?.extrusionH ?? 0;
|
|
20
|
+
const hasBevel = !!(meta.sp3d?.bevelT || meta.sp3d?.bevelB);
|
|
21
|
+
return hasRot || ext > 0 || hasBevel;
|
|
22
|
+
}
|
|
23
|
+
function readMeta(s) {
|
|
24
|
+
return shape3DInfo(s);
|
|
25
|
+
}
|
|
26
|
+
// 烘焙输出的几何/参数签名:bakeSp3d 的全部输入(bbox / fill / stroke / sp3d / scene3d /
|
|
27
|
+
// src / cropRect)。任一变化 → 签名变化 → 需要重烘。id 是身份不入签名。
|
|
28
|
+
function bakeKeyOf(s) {
|
|
29
|
+
const meta = readMeta(s);
|
|
30
|
+
return JSON.stringify({
|
|
31
|
+
k: s.kind,
|
|
32
|
+
b: s.bbox ?? null,
|
|
33
|
+
f: s.fill ?? null,
|
|
34
|
+
st: s.stroke ?? null,
|
|
35
|
+
sp: meta?.sp3d ?? null,
|
|
36
|
+
sc: meta?.scene3d ?? null,
|
|
37
|
+
src: s.kind === "image" ? s.src ?? null : null,
|
|
38
|
+
cr: s.kind === "image" ? s.cropRect ?? null : null,
|
|
39
|
+
d: s.kind === "path" ? s.d : s.kind === "polygon" ? s.points : null,
|
|
40
|
+
tx: s.kind === "text" ? s.paragraphs : null,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// 该 shape 当前是否需要(重新)烘焙:image / 自选形状 / 带 body3D 的文字,
|
|
44
|
+
// 需要 3D 渲染,且"当前几何签名"与上次烘焙记录的 bakedKey 不一致
|
|
45
|
+
// (首次烘焙时 bakedKey 为 undefined,必然不一致)。
|
|
46
|
+
export function needs3DBake(s) {
|
|
47
|
+
if (!shapeSupports3D(s))
|
|
48
|
+
return false;
|
|
49
|
+
const meta = readMeta(s);
|
|
50
|
+
if (!meta || !needs3DRender(meta))
|
|
51
|
+
return false;
|
|
52
|
+
return meta.bakedKey !== bakeKeyOf(s);
|
|
53
|
+
}
|
|
54
|
+
function shouldBake(s) {
|
|
55
|
+
return needs3DBake(s);
|
|
56
|
+
}
|
|
57
|
+
function walkShapes(slide, fn) {
|
|
58
|
+
for (const layer of slide.layers) {
|
|
59
|
+
const stack = [[layer.shapes, 0]];
|
|
60
|
+
while (stack.length) {
|
|
61
|
+
const top = stack[stack.length - 1];
|
|
62
|
+
const [arr, i] = top;
|
|
63
|
+
if (i >= arr.length) {
|
|
64
|
+
stack.pop();
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const s = arr[i];
|
|
68
|
+
top[1] = i + 1;
|
|
69
|
+
fn(s);
|
|
70
|
+
if (s.kind === "group")
|
|
71
|
+
stack.push([s.children, 0]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function createBake3DRuntime() {
|
|
76
|
+
// markBaked 强制跳过集合(外部显式声明"别再烘这个 id")。
|
|
77
|
+
const forceSkip = new Set();
|
|
78
|
+
const inFlight = new Map();
|
|
79
|
+
const bakeOne = async (s, applyBaked, frontImageSrc) => {
|
|
80
|
+
const meta = readMeta(s);
|
|
81
|
+
if (!meta)
|
|
82
|
+
return;
|
|
83
|
+
// 在烘焙前快照签名:bake 是异步的,期间用户可能又动了 shape;用快照写回 bakedKey
|
|
84
|
+
// 保证"已烘的就是这个签名",若烘焙期间又变了,下一轮 kick 的签名不一致会再烘。
|
|
85
|
+
const key = bakeKeyOf(s);
|
|
86
|
+
const front = s.kind === "text" ? frontImageSrc?.(s) : undefined;
|
|
87
|
+
const isPicture = s.kind === "image" || !!front;
|
|
88
|
+
try {
|
|
89
|
+
const result = await bakeSp3d({
|
|
90
|
+
id: s.id,
|
|
91
|
+
bbox: s.bbox,
|
|
92
|
+
pathD: isPicture ? undefined : shape3DPathD(s),
|
|
93
|
+
fill: s.fill,
|
|
94
|
+
stroke: s.stroke,
|
|
95
|
+
sp3d: meta.sp3d ?? {},
|
|
96
|
+
scene3d: meta.scene3d,
|
|
97
|
+
imageSrc: s.kind === "image" ? s.src : front,
|
|
98
|
+
cropRect: s.kind === "image" ? s.cropRect : undefined,
|
|
99
|
+
});
|
|
100
|
+
// baked/bakedKey 仅作 dedupe;不改 shape.src/bbox/cropRect,让 live 视图保持原始几何。
|
|
101
|
+
meta.baked = true;
|
|
102
|
+
if (result)
|
|
103
|
+
applyBaked(s, result);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// 烘焙失败不致命,仍能看到 2D 图。
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
// 成功或失败都记录签名(避免失败时无限重试);下次几何变化签名变了再烘。
|
|
110
|
+
meta.bakedKey = key;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
kick(slide, applyBaked, opts) {
|
|
115
|
+
const front = opts?.frontImageSrc;
|
|
116
|
+
walkShapes(slide, (s) => {
|
|
117
|
+
if (forceSkip.has(s.id) || inFlight.has(s.id))
|
|
118
|
+
return;
|
|
119
|
+
if (!shouldBake(s))
|
|
120
|
+
return;
|
|
121
|
+
const p = bakeOne(s, applyBaked, front).then(() => {
|
|
122
|
+
inFlight.delete(s.id);
|
|
123
|
+
});
|
|
124
|
+
inFlight.set(s.id, p);
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
async waitForAll() {
|
|
128
|
+
while (inFlight.size > 0) {
|
|
129
|
+
await Promise.all(Array.from(inFlight.values()));
|
|
130
|
+
}
|
|
131
|
+
if (typeof requestAnimationFrame !== "undefined") {
|
|
132
|
+
await new Promise((r) => {
|
|
133
|
+
requestAnimationFrame(() => requestAnimationFrame(() => r()));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
markBaked(id) {
|
|
138
|
+
forceSkip.add(id);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { setupCamera } from "./camera.js";
|
|
3
|
+
import { applyLightRig } from "./light-rig.js";
|
|
4
|
+
import { buildMaterial, pickPrimaryColor } from "./material.js";
|
|
5
|
+
import { pathDToShapes } from "./path-to-shape.js";
|
|
6
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
// 共享 three.js 场景构建器。offscreen 光栅(bakeSp3d,给 thumbnail/snapshot/pdf)和
|
|
8
|
+
// live WebGL backend(player/editor 实时)都用它,保证两条路径几何/材质/光照一致。
|
|
9
|
+
// 这里只负责「已解析参数(ResolvedScene3D) → THREE.Scene + 相机」,不碰 DOM / canvas。
|
|
10
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
/**
|
|
12
|
+
* "前缘有倒角、背面平整、薄侧壁" 的板形几何——复刻 PowerPoint sp3d 在仅声明
|
|
13
|
+
* `<a:bevelT/>` / `<a:bevelB/>` 没有 extrusionH 时的视觉(如 7.pptx 棋盘)。
|
|
14
|
+
* 返回 front(贴图面,z=+e/2 内缩 bevelW/H)+ rim(chamfer ring + 薄侧壁),让前面用
|
|
15
|
+
* textured material 保色、rim 用 PBR 吃光照显倒角折边。详细几何见原始注释保留。
|
|
16
|
+
*/
|
|
17
|
+
function buildFrontBeveledSlab(baseW, baseH, extrusionPx, bevelWpx, bevelHpx) {
|
|
18
|
+
const e = extrusionPx;
|
|
19
|
+
const bw = Math.min(bevelWpx, baseW * 0.4);
|
|
20
|
+
const bh = Math.min(bevelHpx, e * 0.5);
|
|
21
|
+
const zFront = +e / 2;
|
|
22
|
+
const zRim = +e / 2 - bh;
|
|
23
|
+
const zBack = -e / 2;
|
|
24
|
+
const buildBuf = (pushAll) => {
|
|
25
|
+
const positions = [];
|
|
26
|
+
const uvs = [];
|
|
27
|
+
const normals = [];
|
|
28
|
+
const pushTri = (p1, p2, p3, uv1, uv2, uv3, n) => {
|
|
29
|
+
positions.push(...p1, ...p2, ...p3);
|
|
30
|
+
uvs.push(...uv1, ...uv2, ...uv3);
|
|
31
|
+
normals.push(...n, ...n, ...n);
|
|
32
|
+
};
|
|
33
|
+
const pushQuad = (a, b, c, d, uva, uvb, uvc, uvd, n) => {
|
|
34
|
+
pushTri(a, b, c, uva, uvb, uvc, n);
|
|
35
|
+
pushTri(a, c, d, uva, uvc, uvd, n);
|
|
36
|
+
};
|
|
37
|
+
pushAll({ pushTri, pushQuad });
|
|
38
|
+
const geo = new THREE.BufferGeometry();
|
|
39
|
+
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
|
40
|
+
geo.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
|
|
41
|
+
geo.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
|
|
42
|
+
return geo;
|
|
43
|
+
};
|
|
44
|
+
const front = buildBuf(({ pushQuad }) => {
|
|
45
|
+
const u0 = bw / baseW, u1 = 1 - bw / baseW;
|
|
46
|
+
const v0 = bh / baseH, v1 = 1 - bh / baseH;
|
|
47
|
+
pushQuad([bw, bh, zFront], [baseW - bw, bh, zFront], [baseW - bw, baseH - bh, zFront], [bw, baseH - bh, zFront], [u0, v0], [u1, v0], [u1, v1], [u0, v1], [0, 0, 1]);
|
|
48
|
+
});
|
|
49
|
+
// 倒角 / 侧壁的 UV 直接取顶点 (x,y) 归一化,让正面贴图**连续地**裹过倒角折边到外缘
|
|
50
|
+
// (复刻 PowerPoint:image 的 bevel 是「图片本身绕圆边卷过去」,不是一圈灰色画框)。
|
|
51
|
+
// 之前 rim 全用 (0,0) + 灰色材质 → 正面与灰边之间出现「折痕」、顶部朝相机的倒角是
|
|
52
|
+
// 一条灰色折面(见 7.pptx slide2 棋盘)。连续 UV + 同一张贴图后,正面与倒角无色差,
|
|
53
|
+
// 折痕消失,倒角只贡献轮廓上的微弱立体感。
|
|
54
|
+
const uv = (x, y) => [
|
|
55
|
+
Math.min(1, Math.max(0, x / baseW)),
|
|
56
|
+
Math.min(1, Math.max(0, y / baseH)),
|
|
57
|
+
];
|
|
58
|
+
const rim = buildBuf(({ pushQuad }) => {
|
|
59
|
+
pushQuad([0, 0, zRim], [baseW, 0, zRim], [baseW - bw, bh, zFront], [bw, bh, zFront], uv(0, 0), uv(baseW, 0), uv(baseW - bw, bh), uv(bw, bh), [0, -0.71, 0.71]);
|
|
60
|
+
pushQuad([bw, baseH - bh, zFront], [baseW - bw, baseH - bh, zFront], [baseW, baseH, zRim], [0, baseH, zRim], uv(bw, baseH - bh), uv(baseW - bw, baseH - bh), uv(baseW, baseH), uv(0, baseH), [0, 0.71, 0.71]);
|
|
61
|
+
pushQuad([0, 0, zRim], [bw, bh, zFront], [bw, baseH - bh, zFront], [0, baseH, zRim], uv(0, 0), uv(bw, bh), uv(bw, baseH - bh), uv(0, baseH), [-0.71, 0, 0.71]);
|
|
62
|
+
pushQuad([baseW - bw, bh, zFront], [baseW, 0, zRim], [baseW, baseH, zRim], [baseW - bw, baseH - bh, zFront], uv(baseW - bw, bh), uv(baseW, 0), uv(baseW, baseH), uv(baseW - bw, baseH - bh), [0.71, 0, 0.71]);
|
|
63
|
+
pushQuad([0, 0, zRim], [0, 0, zBack], [baseW, 0, zBack], [baseW, 0, zRim], uv(0, 0), uv(0, 0), uv(baseW, 0), uv(baseW, 0), [0, -1, 0]);
|
|
64
|
+
pushQuad([baseW, 0, zRim], [baseW, 0, zBack], [baseW, baseH, zBack], [baseW, baseH, zRim], uv(baseW, 0), uv(baseW, 0), uv(baseW, baseH), uv(baseW, baseH), [1, 0, 0]);
|
|
65
|
+
pushQuad([baseW, baseH, zRim], [baseW, baseH, zBack], [0, baseH, zBack], [0, baseH, zRim], uv(baseW, baseH), uv(baseW, baseH), uv(0, baseH), uv(0, baseH), [0, 1, 0]);
|
|
66
|
+
pushQuad([0, baseH, zRim], [0, baseH, zBack], [0, 0, zBack], [0, 0, zRim], uv(0, baseH), uv(0, baseH), uv(0, 0), uv(0, 0), [-1, 0, 0]);
|
|
67
|
+
});
|
|
68
|
+
return { front, rim };
|
|
69
|
+
}
|
|
70
|
+
// THREE.ExtrudeGeometry 默认 UVGenerator 会把侧面拉伸成镜像图。给定 baseW/baseH:
|
|
71
|
+
// - 正/背面:vertex.x/y 归一到 [0,1] 让 image 贴满
|
|
72
|
+
// - 侧面 / 倒角:固定 (0,0),等于纹理左上角实色,让 fillColor / rim 主导
|
|
73
|
+
// V 轴:texture.flipY=true → uv.v=1 对应原图最顶行;geom 顶点 y=baseH(顶部)→ uv.v=1。
|
|
74
|
+
function rectFrontUVGenerator(baseW, baseH) {
|
|
75
|
+
const u = (x) => Math.min(1, Math.max(0, x / baseW));
|
|
76
|
+
const v = (y) => Math.min(1, Math.max(0, y / baseH));
|
|
77
|
+
return {
|
|
78
|
+
generateTopUV: (_geo, vs, ia, ib, ic) => {
|
|
79
|
+
const xy = (i) => new THREE.Vector2(u(vs[i * 3]), v(vs[i * 3 + 1]));
|
|
80
|
+
return [xy(ia), xy(ib), xy(ic)];
|
|
81
|
+
},
|
|
82
|
+
generateSideWallUV: () => {
|
|
83
|
+
const z = new THREE.Vector2(0, 0);
|
|
84
|
+
return [z, z, z, z];
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function buildThreeScene(inputs) {
|
|
89
|
+
const { baseW, baseH, pathD, fill, stroke, resolved, texture, isImage } = inputs;
|
|
90
|
+
const scene = new THREE.Scene();
|
|
91
|
+
const r = resolved.sp3d;
|
|
92
|
+
const fillColor = pickPrimaryColor(fill, "#7f7f7f");
|
|
93
|
+
const explicitExtH = r.extrusionH;
|
|
94
|
+
const bevelWpx = Math.min(r.bevelT?.w ?? r.bevelB?.w ?? 0, baseW * 0.4);
|
|
95
|
+
const bevelHpx = Math.min(r.bevelT?.h ?? r.bevelB?.h ?? 0, baseH * 0.4);
|
|
96
|
+
const hasBevel = r.hasBevel && bevelWpx > 0 && bevelHpx > 0;
|
|
97
|
+
const useFrontBevelSlab = isImage && hasBevel && !pathD;
|
|
98
|
+
const useBevel = hasBevel && !isImage;
|
|
99
|
+
const extrusionPx = explicitExtH > 0
|
|
100
|
+
? explicitExtH
|
|
101
|
+
// image:用 2×bevelHpx 当深度,让 buildFrontBeveledSlab 里 bh=min(bevelHpx, e/2)
|
|
102
|
+
// 取满 bevelHpx → 顶/底倒角折面有足够宽度被看见(否则顶光下与正面分不开)。
|
|
103
|
+
: isImage
|
|
104
|
+
? Math.max(bevelHpx * 2, 8)
|
|
105
|
+
: useBevel
|
|
106
|
+
? Math.max(bevelHpx * 1.5, 6)
|
|
107
|
+
: 1;
|
|
108
|
+
const pp = pathD ? pathDToShapes(pathD) : null;
|
|
109
|
+
let geometry = null;
|
|
110
|
+
let frontGeo = null;
|
|
111
|
+
let rimGeo = null;
|
|
112
|
+
let geoCenterX = 0;
|
|
113
|
+
let geoCenterY = 0;
|
|
114
|
+
let geoW = baseW;
|
|
115
|
+
let geoH = baseH;
|
|
116
|
+
const extrudeOpts = {
|
|
117
|
+
depth: Math.max(extrusionPx, 0.001),
|
|
118
|
+
bevelEnabled: useBevel,
|
|
119
|
+
bevelSegments: 4,
|
|
120
|
+
bevelSize: bevelWpx,
|
|
121
|
+
bevelThickness: bevelHpx,
|
|
122
|
+
curveSegments: 24,
|
|
123
|
+
UVGenerator: rectFrontUVGenerator(baseW, baseH),
|
|
124
|
+
};
|
|
125
|
+
if (pp && pp.shapes.length > 0) {
|
|
126
|
+
const bbox = pp.bbox;
|
|
127
|
+
geoW = bbox.maxX - bbox.minX || baseW;
|
|
128
|
+
geoH = bbox.maxY - bbox.minY || baseH;
|
|
129
|
+
geoCenterX = (bbox.minX + bbox.maxX) / 2;
|
|
130
|
+
geoCenterY = (bbox.minY + bbox.maxY) / 2;
|
|
131
|
+
geometry = new THREE.ExtrudeGeometry(pp.shapes, extrudeOpts);
|
|
132
|
+
}
|
|
133
|
+
else if (useFrontBevelSlab) {
|
|
134
|
+
geoCenterX = baseW / 2;
|
|
135
|
+
geoCenterY = baseH / 2;
|
|
136
|
+
const built = buildFrontBeveledSlab(baseW, baseH, extrusionPx, bevelWpx, bevelHpx);
|
|
137
|
+
frontGeo = built.front;
|
|
138
|
+
rimGeo = built.rim;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
geoCenterX = baseW / 2;
|
|
142
|
+
geoCenterY = baseH / 2;
|
|
143
|
+
const shape = new THREE.Shape();
|
|
144
|
+
shape.moveTo(0, 0);
|
|
145
|
+
shape.lineTo(baseW, 0);
|
|
146
|
+
shape.lineTo(baseW, baseH);
|
|
147
|
+
shape.lineTo(0, baseH);
|
|
148
|
+
shape.lineTo(0, 0);
|
|
149
|
+
geometry = new THREE.ExtrudeGeometry([shape], extrudeOpts);
|
|
150
|
+
}
|
|
151
|
+
const rimColor = r.contourClr ?? fillColor;
|
|
152
|
+
if (frontGeo && rimGeo) {
|
|
153
|
+
frontGeo.translate(-geoCenterX, -geoCenterY, -extrusionPx / 2);
|
|
154
|
+
rimGeo.translate(-geoCenterX, -geoCenterY, -extrusionPx / 2);
|
|
155
|
+
frontGeo.computeBoundingSphere();
|
|
156
|
+
frontGeo.computeBoundingBox();
|
|
157
|
+
rimGeo.computeBoundingSphere();
|
|
158
|
+
rimGeo.computeBoundingBox();
|
|
159
|
+
const frontMat = buildMaterial(fill, {
|
|
160
|
+
prst: r.prstMaterial,
|
|
161
|
+
fallbackColor: fillColor,
|
|
162
|
+
imageTexture: texture ?? undefined,
|
|
163
|
+
});
|
|
164
|
+
// image 倒角 / 侧壁与正面共用贴图(连续 UV),让图片裹过折边而不是露出灰色画框;
|
|
165
|
+
// 但 rim 走 litTexture + tint 压暗:正面 basic 满亮、折边受光后再乘灰度 → 顶光下
|
|
166
|
+
// 「上面(倒角折面)」也明显比「前面(正面)」暗,留出区分两个面的折痕。
|
|
167
|
+
// 无贴图(纯色 shape 不会走 slab 路径,但兜底)时仍回退到 contour / fill 灰边。
|
|
168
|
+
const rimMat = texture
|
|
169
|
+
? buildMaterial(fill, {
|
|
170
|
+
prst: r.prstMaterial,
|
|
171
|
+
fallbackColor: rimColor,
|
|
172
|
+
imageTexture: texture,
|
|
173
|
+
litTexture: true,
|
|
174
|
+
tint: 0x888888,
|
|
175
|
+
})
|
|
176
|
+
: buildMaterial(undefined, { prst: r.prstMaterial, fallbackColor: rimColor });
|
|
177
|
+
scene.add(new THREE.Mesh(frontGeo, frontMat));
|
|
178
|
+
scene.add(new THREE.Mesh(rimGeo, rimMat));
|
|
179
|
+
}
|
|
180
|
+
else if (geometry) {
|
|
181
|
+
geometry.translate(-geoCenterX, -geoCenterY, -extrusionPx / 2);
|
|
182
|
+
geometry.computeBoundingSphere();
|
|
183
|
+
geometry.computeBoundingBox();
|
|
184
|
+
const material = buildMaterial(fill, {
|
|
185
|
+
prst: r.prstMaterial,
|
|
186
|
+
fallbackColor: fillColor,
|
|
187
|
+
imageTexture: texture ?? undefined,
|
|
188
|
+
});
|
|
189
|
+
scene.add(new THREE.Mesh(geometry, material));
|
|
190
|
+
}
|
|
191
|
+
if (stroke && r.contourW && r.contourW > 0) {
|
|
192
|
+
const geomForEdges = frontGeo ?? geometry;
|
|
193
|
+
if (geomForEdges) {
|
|
194
|
+
const edges = new THREE.EdgesGeometry(geomForEdges, 12);
|
|
195
|
+
const lineMat = new THREE.LineBasicMaterial({ color: r.contourClr ?? "#000000", linewidth: 1 });
|
|
196
|
+
scene.add(new THREE.LineSegments(edges, lineMat));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
applyLightRig(scene, resolved.lightRig.rig, resolved.lightRig.dir);
|
|
200
|
+
const dispose = () => {
|
|
201
|
+
scene.traverse((obj) => {
|
|
202
|
+
if (obj instanceof THREE.Mesh || obj instanceof THREE.LineSegments) {
|
|
203
|
+
obj.geometry?.dispose();
|
|
204
|
+
const mat = obj.material;
|
|
205
|
+
if (Array.isArray(mat))
|
|
206
|
+
for (const m of mat)
|
|
207
|
+
m.dispose();
|
|
208
|
+
else
|
|
209
|
+
mat?.dispose();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
return { scene, geoW, geoH, dispose };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* 把相机摆到「物体在 viewW×viewH 视口里接近 1:1(含 pad 余量)」的距离并构建 THREE 相机。
|
|
217
|
+
* offscreen 与 live 共用:offscreen 传 padded canvas 尺寸;live 传显示像素尺寸。
|
|
218
|
+
*/
|
|
219
|
+
export function createSceneCamera(cam, geoW, geoH, viewW, viewH, pad, backdrop) {
|
|
220
|
+
const aspect = viewW / viewH;
|
|
221
|
+
const camFit = 1 + pad;
|
|
222
|
+
let distance;
|
|
223
|
+
if (cam.isOrtho) {
|
|
224
|
+
distance = Math.max(Math.max(geoW, geoH) * 4, 200);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
const fovRad = (cam.fov * Math.PI) / 180;
|
|
228
|
+
const tanHalf = Math.tan(fovRad / 2);
|
|
229
|
+
const dByH = (geoH * camFit) / (2 * tanHalf);
|
|
230
|
+
const dByW = (geoW * camFit) / (2 * tanHalf * aspect);
|
|
231
|
+
distance = Math.max(dByH, dByW, 200);
|
|
232
|
+
}
|
|
233
|
+
const { camera } = setupCamera(cam, new THREE.Vector3(0, 0, 0), distance, backdrop);
|
|
234
|
+
if (camera instanceof THREE.PerspectiveCamera) {
|
|
235
|
+
camera.aspect = aspect;
|
|
236
|
+
camera.updateProjectionMatrix();
|
|
237
|
+
}
|
|
238
|
+
else if (camera instanceof THREE.OrthographicCamera) {
|
|
239
|
+
const halfX = (viewW / 2) * camFit;
|
|
240
|
+
const halfY = (viewH / 2) * camFit;
|
|
241
|
+
camera.left = -halfX;
|
|
242
|
+
camera.right = halfX;
|
|
243
|
+
camera.top = halfY;
|
|
244
|
+
camera.bottom = -halfY;
|
|
245
|
+
camera.updateProjectionMatrix();
|
|
246
|
+
}
|
|
247
|
+
return camera;
|
|
248
|
+
}
|
|
249
|
+
// 已解码的 <img> 缓存(按 src)。解码是 loadImageTexture 里唯一的异步成本;缓存后
|
|
250
|
+
// live WebGLShapeBackend 在每帧重建(编辑器拖动)时可以 **同步** 拿到贴图、当帧首渲,
|
|
251
|
+
// 避免"重建 backend → 异步等贴图 → 3D 空一帧"造成的闪屏。THREE.Texture 不进缓存
|
|
252
|
+
// (它绑定具体 renderer 的 GPU 资源、且会被各自 backend dispose),每次按缓存的
|
|
253
|
+
// HTMLImageElement 新建一个轻量 Texture 即可。
|
|
254
|
+
const decodedImageCache = new Map();
|
|
255
|
+
function textureFromImage(img) {
|
|
256
|
+
const tex = new THREE.Texture(img);
|
|
257
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
258
|
+
tex.needsUpdate = true;
|
|
259
|
+
return tex;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* 若 src 对应图片此前已被 loadImageTexture 解码过,则同步返回一个新 Texture;否则
|
|
263
|
+
* 返回 null(调用方需走异步 loadImageTexture)。用于热路径(拖动重建)下零延迟首渲。
|
|
264
|
+
*/
|
|
265
|
+
export function getCachedImageTexture(src) {
|
|
266
|
+
const img = decodedImageCache.get(src);
|
|
267
|
+
if (!img || !img.complete || img.naturalWidth === 0)
|
|
268
|
+
return null;
|
|
269
|
+
return textureFromImage(img);
|
|
270
|
+
}
|
|
271
|
+
// dataURL / URL 都走 Image → THREE.Texture,避免 TextureLoader 在 headless 对 dataURL 失败。
|
|
272
|
+
export async function loadImageTexture(src) {
|
|
273
|
+
if (typeof Image === "undefined")
|
|
274
|
+
return null;
|
|
275
|
+
const cached = decodedImageCache.get(src);
|
|
276
|
+
if (cached && cached.complete && cached.naturalWidth > 0) {
|
|
277
|
+
return textureFromImage(cached);
|
|
278
|
+
}
|
|
279
|
+
const img = await new Promise((resolve) => {
|
|
280
|
+
const i = new Image();
|
|
281
|
+
i.crossOrigin = "anonymous";
|
|
282
|
+
i.onload = () => resolve(i);
|
|
283
|
+
i.onerror = () => resolve(null);
|
|
284
|
+
i.src = src;
|
|
285
|
+
});
|
|
286
|
+
if (!img || !img.complete || img.naturalWidth === 0)
|
|
287
|
+
return null;
|
|
288
|
+
decodedImageCache.set(src, img);
|
|
289
|
+
return textureFromImage(img);
|
|
290
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// 实时 WebGL 与离屏烘焙共用的形状范围。文字框用正面贴图(字形画进这张图)再挤出。
|
|
2
|
+
// 组合本身不进;组合里的子形状各自判断。
|
|
3
|
+
const SHAPE_3D_KINDS = new Set(["image", "rect", "ellipse", "path", "polygon", "text"]);
|
|
4
|
+
/** 与 bake-sp3d 的 image pad 一致:贴图几何收在 bbox 内。 */
|
|
5
|
+
export const IMAGE_3D_PAD = 0.05;
|
|
6
|
+
/** 与 bake-sp3d 的非 image pad 一致:倒角会对称外扩。 */
|
|
7
|
+
export const SHAPE_3D_PAD = 0.15;
|
|
8
|
+
export function shapeSupports3D(s) {
|
|
9
|
+
return SHAPE_3D_KINDS.has(s.kind);
|
|
10
|
+
}
|
|
11
|
+
export function shape3DPad(kind) {
|
|
12
|
+
// 文字正面是字形贴图,留白和 image slab 一样,避免倒角把字挤出画布。
|
|
13
|
+
return kind === "image" || kind === "text" ? IMAGE_3D_PAD : SHAPE_3D_PAD;
|
|
14
|
+
}
|
|
15
|
+
/** 形状上真正参加 3D 的那份参数。文字只看 bodyPr,避免和形状自己的 spPr 3D 各挤出一次。 */
|
|
16
|
+
export function shape3DInfo(s) {
|
|
17
|
+
if (s.kind === "text")
|
|
18
|
+
return s.body3D;
|
|
19
|
+
return s.meta3D;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 非 image 形状的挤出轮廓,坐标系是形状本地的 0..bbox。
|
|
23
|
+
* image 不需要轮廓(正面是贴图 slab)。rect 也不返回:scene-builder 在没有 pathD
|
|
24
|
+
* 时已经按 bbox 挤出一个矩形。path 的 `d` 原样传入,由场景构建器按路径自身包围盒居中。
|
|
25
|
+
*/
|
|
26
|
+
export function shape3DPathD(s) {
|
|
27
|
+
if (s.kind === "path")
|
|
28
|
+
return s.d || undefined;
|
|
29
|
+
if (s.kind === "ellipse")
|
|
30
|
+
return ellipsePathD(s.bbox.w, s.bbox.h);
|
|
31
|
+
if (s.kind === "polygon" && s.points.length > 0)
|
|
32
|
+
return polygonPathD(s);
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
function ellipsePathD(w, h) {
|
|
36
|
+
const rx = w / 2;
|
|
37
|
+
const ry = h / 2;
|
|
38
|
+
const k = 0.5522847498;
|
|
39
|
+
const cx = rx;
|
|
40
|
+
const cy = ry;
|
|
41
|
+
const kx = rx * k;
|
|
42
|
+
const ky = ry * k;
|
|
43
|
+
return [
|
|
44
|
+
`M ${cx} 0`,
|
|
45
|
+
`C ${cx + kx} 0 ${w} ${cy - ky} ${w} ${cy}`,
|
|
46
|
+
`C ${w} ${cy + ky} ${cx + kx} ${h} ${cx} ${h}`,
|
|
47
|
+
`C ${cx - kx} ${h} 0 ${cy + ky} 0 ${cy}`,
|
|
48
|
+
`C 0 ${cy - ky} ${cx - kx} 0 ${cx} 0`,
|
|
49
|
+
"Z",
|
|
50
|
+
].join(" ");
|
|
51
|
+
}
|
|
52
|
+
function polygonPathD(s) {
|
|
53
|
+
const minX = Math.min(...s.points.map((p) => p[0]));
|
|
54
|
+
const minY = Math.min(...s.points.map((p) => p[1]));
|
|
55
|
+
const absolute = Math.abs(minX - s.bbox.x) < 2 && Math.abs(minY - s.bbox.y) < 2;
|
|
56
|
+
const pts = absolute
|
|
57
|
+
? s.points.map(([x, y]) => [x - s.bbox.x, y - s.bbox.y])
|
|
58
|
+
: s.points;
|
|
59
|
+
const [x0, y0] = pts[0];
|
|
60
|
+
let d = `M ${x0} ${y0}`;
|
|
61
|
+
for (let i = 1; i < pts.length; i++)
|
|
62
|
+
d += ` L ${pts[i][0]} ${pts[i][1]}`;
|
|
63
|
+
return `${d} Z`;
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { ResolvedBackdrop, ResolvedCamera } from "./resolve.js";
|
|
3
|
+
export interface CameraResult {
|
|
4
|
+
camera: THREE.Camera;
|
|
5
|
+
isOrtho: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function setupCamera(cam: ResolvedCamera, target: THREE.Vector3, distance: number, backdrop?: ResolvedBackdrop): CameraResult;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export { bakeSp3d } from "./bake-sp3d.js";
|
|
3
|
+
export { createBake3DRuntime, needs3DRender, needs3DBake, type Bake3DRuntime } from "./runtime.js";
|
|
4
|
+
export { resolveScene3D, resolveCamera, resolveSp3D, resolveLightRig, resolveBackdrop, type ResolvedScene3D, type ResolvedCamera, type ResolvedSp3D, type ResolvedLightRig, type ResolvedBevel, type ResolvedBackdrop, } from "./resolve.js";
|
|
5
|
+
export { buildThreeScene, createSceneCamera, loadImageTexture, getCachedImageTexture, type BuildSceneInputs, type BuiltScene, } from "./scene-builder.js";
|
|
6
|
+
export { applyTextureCrop } from "./material.js";
|
|
7
|
+
export { shapeSupports3D, shape3DInfo, shape3DPathD, shape3DPad, IMAGE_3D_PAD, SHAPE_3D_PAD, } from "./shape-path.js";
|
|
8
|
+
export { CAMERA_PRESETS, type CameraPreset } from "./presets.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
export interface LightRigSpec {
|
|
3
|
+
ambient: number;
|
|
4
|
+
lights: Array<{
|
|
5
|
+
color: number;
|
|
6
|
+
intensity: number;
|
|
7
|
+
pos: [number, number, number];
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
export declare function applyLightRig(scene: THREE.Scene, rigName: string | undefined, dir: string | undefined): void;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { Paint } from "@sliiu/present-core";
|
|
3
|
+
export interface MaterialOpts {
|
|
4
|
+
prst?: string;
|
|
5
|
+
fallbackColor?: string;
|
|
6
|
+
imageTexture?: THREE.Texture;
|
|
7
|
+
litTexture?: boolean;
|
|
8
|
+
tint?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function pickPrimaryColor(paint: Paint | undefined, fallback: string): string;
|
|
11
|
+
export declare function buildMaterial(paint: Paint | undefined, opts?: MaterialOpts): THREE.Material;
|
|
12
|
+
export declare function applyTextureCrop(texture: THREE.Texture, cropRect: {
|
|
13
|
+
l: number;
|
|
14
|
+
t: number;
|
|
15
|
+
r: number;
|
|
16
|
+
b: number;
|
|
17
|
+
} | undefined): void;
|