@dreamerbird/camera-studio 0.1.0 → 0.2.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/LICENSE +866 -0
- package/assets/models/Y Bot.glb +0 -0
- package/dist/CameraScene.d.ts.map +1 -1
- package/dist/CameraScene.js +66 -5
- package/dist/PlacementManager.d.ts.map +1 -1
- package/dist/PlacementManager.js +29 -2
- package/dist/assetLibrary/exportGLB.d.ts +25 -0
- package/dist/assetLibrary/exportGLB.d.ts.map +1 -1
- package/dist/assetLibrary/exportGLB.js +43 -1
- package/dist/assetLibrary/exportGLB.test.js +56 -2
- package/dist/assetLibrary/seedAssetsManifest.js +1 -1
- package/dist/firstScreenGate.d.ts +28 -0
- package/dist/firstScreenGate.d.ts.map +1 -0
- package/dist/firstScreenGate.js +67 -0
- package/dist/firstScreenGate.test.d.ts +2 -0
- package/dist/firstScreenGate.test.d.ts.map +1 -0
- package/dist/firstScreenGate.test.js +83 -0
- package/package.json +10 -10
package/assets/models/Y Bot.glb
CHANGED
|
Binary file
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CameraScene.d.ts","sourceRoot":"","sources":["../src/CameraScene.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAiB,YAAY,EAAC,MAAM,mBAAmB,CAAA;AACnE,OAAO,KASN,MAAM,OAAO,CAAA;AAsDd,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,mBAAmB,CAAA;AAiBjD,OAAO,KAAK,EAAc,UAAU,EAAC,MAAM,kBAAkB,CAAA;AAyD7D,OAAO,KAAK,EAAC,WAAW,EAAe,MAAM,aAAa,CAAA;AAG1D,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"CameraScene.d.ts","sourceRoot":"","sources":["../src/CameraScene.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAiB,YAAY,EAAC,MAAM,mBAAmB,CAAA;AACnE,OAAO,KASN,MAAM,OAAO,CAAA;AAsDd,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,mBAAmB,CAAA;AAiBjD,OAAO,KAAK,EAAc,UAAU,EAAC,MAAM,kBAAkB,CAAA;AAyD7D,OAAO,KAAK,EAAC,WAAW,EAAe,MAAM,aAAa,CAAA;AAG1D,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,UAAU,CAAA;AAwmB9C;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,YAAY,CAAC,GAAG,CAAC,GAAG,SAAS,GACrC,UAAU,GAAG,SAAS,CAMxB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,YAAY,CAAC,GAAG,CAAC,GAAG,SAAS,GACrC,UAAU,CAcZ;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,EAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAC,EAAE,gBAAgB,qBAijDxE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,EAAC,QAAQ,EAAC,EAAE;IAAC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAAC,qBAIpE;AAuFD,8BAA8B;AAC9B,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,OAAO,GACR,EAAE;IACD,KAAK,EAAE,WAAW,CAAA;IAClB,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,WAAW,KAAK,IAAI,CAAA;CACnC,QAOA"}
|
package/dist/CameraScene.js
CHANGED
|
@@ -50,12 +50,25 @@ import { createHostFlows, setDropPointContext, setHostFlowsHandle, setPlacingSna
|
|
|
50
50
|
import { importModelFile } from './assetLibrary/importModelFile';
|
|
51
51
|
import { resolveControlSpec } from './poseMode/controlSpec';
|
|
52
52
|
import { getUiConfig } from './uiConfig';
|
|
53
|
+
import { createFirstScreenGate, getFirstScreenGate, installFirstScreenGate, resetFirstScreenGate, } from './firstScreenGate';
|
|
53
54
|
import { enterPoseSession, getPoseSession, isModelDoubleClick, poseHitTest, requestPoseExit, usePoseSessionKey, } from './poseMode/poseSession';
|
|
54
55
|
import { getPoseKeyframeCount, setPoseValue, writePoseKeyframe, clearPoseValue, } from './poseMode/poseTrack';
|
|
55
56
|
import { savePoseFromInstance } from './poseMode/savePose';
|
|
56
57
|
import { parsePoseValue, serializeLibPose } from './poseMode/poseValue';
|
|
57
58
|
import { isPoseExitGuardedTarget } from './poseMode/poseExitGuard';
|
|
58
59
|
import { poseStatusText } from './poseStatusText';
|
|
60
|
+
/** 首屏闸门里场景内置模型(scene.glb)的登记 key */
|
|
61
|
+
const SCENE_MODEL_GATE_KEY = 'scene-glb';
|
|
62
|
+
/**
|
|
63
|
+
* 加载探针:Suspense 未解决时子树不挂载,mount effect 触发即
|
|
64
|
+
* 「这个资源落定了」,上报首屏闸门(firstScreenGate 机制)
|
|
65
|
+
*/
|
|
66
|
+
function InitialLoadProbe({ gateKey, children, }) {
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
getFirstScreenGate().settle(gateKey);
|
|
69
|
+
}, [gateKey]);
|
|
70
|
+
return React.createElement(React.Fragment, null, children);
|
|
71
|
+
}
|
|
59
72
|
function Model({ url, selectLocked }) {
|
|
60
73
|
const { nodes } = useGLTF(url);
|
|
61
74
|
const thingyObjRef = useRef(undefined);
|
|
@@ -100,10 +113,11 @@ function Model({ url, selectLocked }) {
|
|
|
100
113
|
/**
|
|
101
114
|
* 导演视角初始机位/瞄准点:PerspectiveCamera/Target 的 JSX position 与
|
|
102
115
|
* Agent view.reset(MainOrbitControls 登记的导演控制器)同源,改一处
|
|
103
|
-
*
|
|
116
|
+
* 两处一致。场景单位 = 米:机位 4.5 米外、1.7 米高,瞄准胸口(0.9 米),
|
|
117
|
+
* 一个 1.8 米的人占画面约四成高,是舒服的工作取景。
|
|
104
118
|
*/
|
|
105
|
-
const DIRECTOR_HOME_CAMERA_POSITION = [0,
|
|
106
|
-
const DIRECTOR_HOME_TARGET_POSITION = [0, 0, 0];
|
|
119
|
+
const DIRECTOR_HOME_CAMERA_POSITION = [0, 1.7, 4.5];
|
|
120
|
+
const DIRECTOR_HOME_TARGET_POSITION = [0, 0.9, 0];
|
|
107
121
|
/**
|
|
108
122
|
* 主画面的视角控制,操作方式与 Blender 默认键位一致:
|
|
109
123
|
* 中键拖动 = 旋转视角;Shift+中键 = 平移;Ctrl/Cmd+中键 = 平滑缩放;
|
|
@@ -496,6 +510,7 @@ const BACKGROUND = new Color().setRGB(0x45 / 255, 0x45 / 255, 0x45 / 255, Linear
|
|
|
496
510
|
function GroundGrid() {
|
|
497
511
|
const ref = useRef(null);
|
|
498
512
|
useEffect(() => registerEditorOnly(ref.current), []);
|
|
513
|
+
// 场景单位 = 米:小格 1 米、粗线 5 米
|
|
499
514
|
return (React.createElement(Grid, { ref: ref, position: [0, 0.01, 0], args: [100, 100], cellSize: 1, cellColor: "#9b9ba1", sectionSize: 5, sectionColor: "#aeaeb6", fadeDistance: 80, fadeStrength: 1, infiniteGrid: true, raycast: () => null }));
|
|
500
515
|
}
|
|
501
516
|
/**
|
|
@@ -1419,6 +1434,30 @@ export function CameraScene({ project, onFlows, onError }) {
|
|
|
1419
1434
|
const handleTogglePath = useMemo(() => () => toggleFrame('path'), [toggleFrame]);
|
|
1420
1435
|
// 稳定引用:行为不变(setPipVisible 取反),与 handleToggleModel/Path 同模式
|
|
1421
1436
|
const handleTogglePip = useCallback(() => setPipVisible((v) => !v), []);
|
|
1437
|
+
// 首屏闸门(firstScreenGate 机制):创建即安装——子树 effect 先于父 effect
|
|
1438
|
+
// 运行,useState 初始化器(渲染期)是子树 effect 能拿到闸门的最早时机;
|
|
1439
|
+
// 卸载时若单值仍是本闸门则复位(重挂载由新实例重装)
|
|
1440
|
+
const [firstScreenReady, setFirstScreenReady] = useState(false);
|
|
1441
|
+
const [coverGone, setCoverGone] = useState(false);
|
|
1442
|
+
const [firstScreenGate] = useState(() => {
|
|
1443
|
+
const gate = createFirstScreenGate(() => setFirstScreenReady(true));
|
|
1444
|
+
gate.expect(SCENE_MODEL_GATE_KEY);
|
|
1445
|
+
installFirstScreenGate(gate);
|
|
1446
|
+
return gate;
|
|
1447
|
+
});
|
|
1448
|
+
useEffect(() => {
|
|
1449
|
+
return () => {
|
|
1450
|
+
if (getFirstScreenGate() === firstScreenGate)
|
|
1451
|
+
resetFirstScreenGate();
|
|
1452
|
+
};
|
|
1453
|
+
}, [firstScreenGate]);
|
|
1454
|
+
// 遮罩淡出(0.3s)+ 余量后卸载,不依赖 transitionend 单一路径
|
|
1455
|
+
useEffect(() => {
|
|
1456
|
+
if (!firstScreenReady)
|
|
1457
|
+
return;
|
|
1458
|
+
const t = setTimeout(() => setCoverGone(true), 600);
|
|
1459
|
+
return () => clearTimeout(t);
|
|
1460
|
+
}, [firstScreenReady]);
|
|
1422
1461
|
return (React.createElement(SceneI18nRoot, null,
|
|
1423
1462
|
React.createElement("div", { style: {
|
|
1424
1463
|
height: '100vh',
|
|
@@ -1595,7 +1634,8 @@ export function CameraScene({ project, onFlows, onError }) {
|
|
|
1595
1634
|
React.createElement("boxGeometry", { attach: "geometry" }),
|
|
1596
1635
|
React.createElement("meshPhongMaterial", { attach: "material", color: "red" })),
|
|
1597
1636
|
React.createElement(Suspense, { fallback: null },
|
|
1598
|
-
React.createElement(
|
|
1637
|
+
React.createElement(InitialLoadProbe, { gateKey: SCENE_MODEL_GATE_KEY },
|
|
1638
|
+
React.createElement(Model, { url: sceneGLB, selectLocked: !!placing }))),
|
|
1599
1639
|
placing && (React.createElement(Suspense, { fallback: null },
|
|
1600
1640
|
React.createElement(PlacementRigErrorBoundary, { onCancel: () => {
|
|
1601
1641
|
cancelPlacing();
|
|
@@ -1614,7 +1654,28 @@ export function CameraScene({ project, onFlows, onError }) {
|
|
|
1614
1654
|
placePresetPath(presetDrag, p);
|
|
1615
1655
|
setPresetDrag(null);
|
|
1616
1656
|
}, onCancel: () => setPresetDrag(null) })),
|
|
1617
|
-
React.createElement(GroundGrid, null)))
|
|
1657
|
+
React.createElement(GroundGrid, null))),
|
|
1658
|
+
!coverGone && (React.createElement("div", { "aria-hidden": true, style: {
|
|
1659
|
+
position: 'absolute',
|
|
1660
|
+
inset: 0,
|
|
1661
|
+
zIndex: getUiConfig().zIndexBase + 100,
|
|
1662
|
+
background: '#454545',
|
|
1663
|
+
display: 'flex',
|
|
1664
|
+
alignItems: 'center',
|
|
1665
|
+
justifyContent: 'center',
|
|
1666
|
+
opacity: firstScreenReady ? 0 : 1,
|
|
1667
|
+
transition: 'opacity 0.3s ease',
|
|
1668
|
+
pointerEvents: firstScreenReady ? 'none' : 'auto',
|
|
1669
|
+
} },
|
|
1670
|
+
React.createElement("style", null, `@keyframes cameraStudioFirstScreenSpin{to{transform:rotate(360deg)}}`),
|
|
1671
|
+
React.createElement("div", { style: {
|
|
1672
|
+
width: 28,
|
|
1673
|
+
height: 28,
|
|
1674
|
+
borderRadius: '50%',
|
|
1675
|
+
border: '3px solid rgba(255,255,255,0.25)',
|
|
1676
|
+
borderTopColor: '#ffffff',
|
|
1677
|
+
animation: 'cameraStudioFirstScreenSpin 0.8s linear infinite',
|
|
1678
|
+
} }))))));
|
|
1618
1679
|
}
|
|
1619
1680
|
/**
|
|
1620
1681
|
* i18n 嵌套判定(spec §7):宿主树已包 studio 的 I18nProvider →
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PlacementManager.d.ts","sourceRoot":"","sources":["../src/PlacementManager.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAC,QAAQ,EAAU,MAAM,EAAE,YAAY,EAAC,MAAM,mBAAmB,CAAA;AAW7E,OAAO,KAQN,MAAM,OAAO,CAAA;AAUd,OAAO,KAAK,EAAC,KAAK,EAAE,QAAQ,EAAE,aAAa,EAAkB,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"PlacementManager.d.ts","sourceRoot":"","sources":["../src/PlacementManager.tsx"],"names":[],"mappings":"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"}
|
package/dist/PlacementManager.js
CHANGED
|
@@ -14,6 +14,8 @@ import { SkeletonUtils } from 'three-stdlib';
|
|
|
14
14
|
import { registerBoneAnchors, unregisterBoneAnchors } from './boneAnchors';
|
|
15
15
|
import { notifyTargetRegistryChange, sheetObjectExists } from './aimTargets';
|
|
16
16
|
import { createGLTFLoader } from './assetLibrary/gltfLoader';
|
|
17
|
+
import { metricScaleFactor } from './assetLibrary/exportGLB';
|
|
18
|
+
import { getFirstScreenGate } from './firstScreenGate';
|
|
17
19
|
import { getLibrary } from './assetLibrary/library';
|
|
18
20
|
import { LibraryError } from './assetLibrary/source';
|
|
19
21
|
import { entryOptions } from './assetLibrary/options';
|
|
@@ -327,14 +329,18 @@ function usePlacementsList(sheet) {
|
|
|
327
329
|
}, [registry]);
|
|
328
330
|
return list;
|
|
329
331
|
}
|
|
330
|
-
/**
|
|
332
|
+
/** 米制兜底:入库时源单位已换算成米烘进资产(exportGLB.bakeMetricScale),
|
|
333
|
+
* 这里只对未烘焙来源(local 直接拖文件、旧库存)纠偏——已烘焙资产算出
|
|
334
|
+
* 的系数恰好是 1,theatre 缩放属性得以 1 为干净基线。底部对齐落点。
|
|
335
|
+
* ⚠️ 旧库存是「归一化成 5 单位」时代的资产(2026-10-21 前),量出来
|
|
336
|
+
* maxDim=5 不会触发厘米换算,放出来是 5 米高的巨人——重新导入即得米制。 */
|
|
331
337
|
function useNormalizedModel(fbx) {
|
|
332
338
|
return useMemo(() => {
|
|
333
339
|
const box = new Box3().setFromObject(fbx);
|
|
334
340
|
const size = box.getSize(new Vector3());
|
|
335
341
|
const maxDim = Math.max(size.x, size.y, size.z);
|
|
336
342
|
return {
|
|
337
|
-
scale: maxDim > 0 ?
|
|
343
|
+
scale: maxDim > 0 ? metricScaleFactor(maxDim) : 1,
|
|
338
344
|
// lift 是模型局部单位,放进 scale 过的组里后底部正好落在 y=0
|
|
339
345
|
lift: -box.min.y,
|
|
340
346
|
};
|
|
@@ -664,6 +670,19 @@ export function PlacedModels({ sheet, project, pendingEntry, onPersisted, select
|
|
|
664
670
|
const entries = pendingEntry && !list.some((e) => e.key === pendingEntry.key)
|
|
665
671
|
? [...list, pendingEntry]
|
|
666
672
|
: list;
|
|
673
|
+
// 首屏闸门(firstScreenGate 机制):先登记本批要等的模型、再报工程就绪,
|
|
674
|
+
// 顺序不可换——先报就绪而 pending 为空会提前揭幕。已落定的 key gate
|
|
675
|
+
// 内部防回加,清单变化重跑只补登新条目;落定后全部调用是 no-op
|
|
676
|
+
const entriesKey = entries.map((e) => e.key).join('|');
|
|
677
|
+
useEffect(() => {
|
|
678
|
+
if (!ready)
|
|
679
|
+
return;
|
|
680
|
+
const gate = getFirstScreenGate();
|
|
681
|
+
for (const e of entries)
|
|
682
|
+
gate.expect(e.key);
|
|
683
|
+
gate.setProjectReady();
|
|
684
|
+
// entries 用 join 串做依赖:内容没变不重复跑
|
|
685
|
+
}, [ready, entriesKey]);
|
|
667
686
|
if (!ready)
|
|
668
687
|
return null;
|
|
669
688
|
return (React.createElement(React.Fragment, null, entries.map((entry) => (React.createElement(Suspense, { key: entry.key, fallback: null },
|
|
@@ -745,6 +764,12 @@ function LibraryPlacedModel({ entry, project, sheet, onPersisted, selectLocked,
|
|
|
745
764
|
}
|
|
746
765
|
/** 已加载 Group 之后的公共逻辑:克隆、归一化、动画 mixer、点选菜单、持久化 */
|
|
747
766
|
function PlacedModelCore({ entry, sheet, group, animations, sourceType, onPersisted, selectLocked, onObjectMenu, }) {
|
|
767
|
+
// 两条加载路径(库 GLB / 本地 FBX)都在这里汇合:挂载即「这个模型
|
|
768
|
+
// 落定了」,上报首屏闸门(失败路径由 PlacedModelErrorBoundary 上报)
|
|
769
|
+
useEffect(() => {
|
|
770
|
+
getFirstScreenGate().settle(entry.key);
|
|
771
|
+
// entry.key 挂载期内不变
|
|
772
|
+
}, []);
|
|
748
773
|
const additionalProps = useMemo(() => {
|
|
749
774
|
const modelEntryId = sourceType === 'library' ? entry.libraryEntryId : undefined;
|
|
750
775
|
const props = {
|
|
@@ -1176,6 +1201,8 @@ class PlacedModelErrorBoundary extends React.Component {
|
|
|
1176
1201
|
}
|
|
1177
1202
|
componentDidCatch(err) {
|
|
1178
1203
|
console.warn(`[placement] 模型 ${this.props.entry.key} 加载失败`, err);
|
|
1204
|
+
// 失败也是落定:首屏闸门不能因一个坏模型一直挂遮罩
|
|
1205
|
+
getFirstScreenGate().settle(this.props.entry.key);
|
|
1179
1206
|
// Agent models.create 正等着挂载完成(Task 8):失败必须 reject waiter,
|
|
1180
1207
|
// 不能悬挂;UI 路径没有 waiter,rejectPlacement 是 no-op
|
|
1181
1208
|
rejectPlacement(this.props.entry.key, err);
|
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import { Group } from 'three';
|
|
2
2
|
import type { AnimationClip, Object3D } from 'three';
|
|
3
|
+
/**
|
|
4
|
+
* 场景单位 = 米(2026-10-21 起,替代此前「最长边统一拉成 5 单位」的
|
|
5
|
+
* 归一化约定)。glTF 标准本来就是米,人高 1.8 单位、网格 1 小格 = 1 米,
|
|
6
|
+
* 模型之间保持真实世界的相对大小(车 4.5 / 人 1.8 / 杯子 0.1)。
|
|
7
|
+
* PlacementManager 的 useNormalizedModel 用同一套换算兜底未烘焙来源。
|
|
8
|
+
*/
|
|
9
|
+
export declare const SCENE_UNIT_METERS = 1;
|
|
10
|
+
/**
|
|
11
|
+
* 厘米源判定阈值:最长边超过这个数就按厘米源处理(÷100)。
|
|
12
|
+
* Mixamo 等 FBX 是厘米(人 180),标准 GLB 是米(人 1.8)。取 50 是因为
|
|
13
|
+
* 米制模型最长边到 50(=50 米,一栋楼)在本系统里罕见,而厘米源几乎
|
|
14
|
+
* 都远超(一个人就 180)。猜错的入口(超大道具/建筑)保留手动改缩放。
|
|
15
|
+
*/
|
|
16
|
+
export declare const CENTIMETER_SOURCE_THRESHOLD = 50;
|
|
17
|
+
/** 源尺寸最长边 → 米制换算系数(米源返回 1,厘米源返回 0.01) */
|
|
18
|
+
export declare function metricScaleFactor(maxDim: number): number;
|
|
19
|
+
/**
|
|
20
|
+
* 把「源单位 → 米」的换算系数烘进根节点 scale(入库前置)。
|
|
21
|
+
* 源文件单位五花八门(Mixamo FBX 是厘米、GLB 多是米),烘过之后任何
|
|
22
|
+
* 模型都是真实米制尺寸,放置时 theatre 对象的缩放属性才能以 1 为
|
|
23
|
+
* 干净基线(1 = 真实大小,2 = 两倍大)。只动根节点变换:蒙皮随骨骼
|
|
24
|
+
* 世界变换正确缩放,骨骼局部数据(动画轨道寻址的那层)不受影响。
|
|
25
|
+
* 返回应用的系数(1 = 本来就是米,没动)。
|
|
26
|
+
*/
|
|
27
|
+
export declare function bakeMetricScale(root: Object3D): number;
|
|
3
28
|
/**
|
|
4
29
|
* 移除无法导出的贴图(image 数据缺失)。
|
|
5
30
|
* FBX 常引用本机不存在的外部贴图文件,加载失败后留下 image 为空的 Texture,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"exportGLB.d.ts","sourceRoot":"","sources":["../../src/assetLibrary/exportGLB.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"exportGLB.d.ts","sourceRoot":"","sources":["../../src/assetLibrary/exportGLB.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,EAAU,MAAM,OAAO,CAAA;AAChD,OAAO,KAAK,EAAC,aAAa,EAAQ,QAAQ,EAAU,MAAM,OAAO,CAAA;AAIjE;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,IAAI,CAAA;AAElC;;;;;GAKG;AACH,eAAO,MAAM,2BAA2B,KAAK,CAAA;AAE7C,yCAAyC;AACzC,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAStD;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CA6B3D;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,QAAQ,GAAG,KAAK,CAoBnD;AAgBD,0CAA0C;AAC1C,wBAAgB,cAAc,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,WAAW,CAAC,CAgBnE;AAED,6BAA6B;AAC7B,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,QAAQ,EACd,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAEtB"}
|
|
@@ -1,6 +1,44 @@
|
|
|
1
|
-
import { Bone, Group } from 'three';
|
|
1
|
+
import { Bone, Box3, Group, Vector3 } from 'three';
|
|
2
2
|
import { GLTFExporter } from 'three-stdlib';
|
|
3
3
|
import { compressGLBDraco } from './draco';
|
|
4
|
+
/**
|
|
5
|
+
* 场景单位 = 米(2026-10-21 起,替代此前「最长边统一拉成 5 单位」的
|
|
6
|
+
* 归一化约定)。glTF 标准本来就是米,人高 1.8 单位、网格 1 小格 = 1 米,
|
|
7
|
+
* 模型之间保持真实世界的相对大小(车 4.5 / 人 1.8 / 杯子 0.1)。
|
|
8
|
+
* PlacementManager 的 useNormalizedModel 用同一套换算兜底未烘焙来源。
|
|
9
|
+
*/
|
|
10
|
+
export const SCENE_UNIT_METERS = 1;
|
|
11
|
+
/**
|
|
12
|
+
* 厘米源判定阈值:最长边超过这个数就按厘米源处理(÷100)。
|
|
13
|
+
* Mixamo 等 FBX 是厘米(人 180),标准 GLB 是米(人 1.8)。取 50 是因为
|
|
14
|
+
* 米制模型最长边到 50(=50 米,一栋楼)在本系统里罕见,而厘米源几乎
|
|
15
|
+
* 都远超(一个人就 180)。猜错的入口(超大道具/建筑)保留手动改缩放。
|
|
16
|
+
*/
|
|
17
|
+
export const CENTIMETER_SOURCE_THRESHOLD = 50;
|
|
18
|
+
/** 源尺寸最长边 → 米制换算系数(米源返回 1,厘米源返回 0.01) */
|
|
19
|
+
export function metricScaleFactor(maxDim) {
|
|
20
|
+
return maxDim > CENTIMETER_SOURCE_THRESHOLD ? 0.01 : SCENE_UNIT_METERS;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 把「源单位 → 米」的换算系数烘进根节点 scale(入库前置)。
|
|
24
|
+
* 源文件单位五花八门(Mixamo FBX 是厘米、GLB 多是米),烘过之后任何
|
|
25
|
+
* 模型都是真实米制尺寸,放置时 theatre 对象的缩放属性才能以 1 为
|
|
26
|
+
* 干净基线(1 = 真实大小,2 = 两倍大)。只动根节点变换:蒙皮随骨骼
|
|
27
|
+
* 世界变换正确缩放,骨骼局部数据(动画轨道寻址的那层)不受影响。
|
|
28
|
+
* 返回应用的系数(1 = 本来就是米,没动)。
|
|
29
|
+
*/
|
|
30
|
+
export function bakeMetricScale(root) {
|
|
31
|
+
const box = new Box3().setFromObject(root);
|
|
32
|
+
const size = box.getSize(new Vector3());
|
|
33
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
34
|
+
if (!(maxDim > 0))
|
|
35
|
+
return 1;
|
|
36
|
+
const factor = metricScaleFactor(maxDim);
|
|
37
|
+
if (factor === 1)
|
|
38
|
+
return 1;
|
|
39
|
+
root.scale.multiplyScalar(factor);
|
|
40
|
+
return factor;
|
|
41
|
+
}
|
|
4
42
|
/**
|
|
5
43
|
* 移除无法导出的贴图(image 数据缺失)。
|
|
6
44
|
* FBX 常引用本机不存在的外部贴图文件,加载失败后留下 image 为空的 Texture,
|
|
@@ -75,6 +113,10 @@ export function exportModelGLB(root) {
|
|
|
75
113
|
if (stripped > 0) {
|
|
76
114
|
console.warn(`[assetLibrary] ${stripped} 张贴图数据无效(源文件引用的贴图缺失或加载失败),已移除后继续导出,模型将以灰模/部分贴图入库`);
|
|
77
115
|
}
|
|
116
|
+
const factor = bakeMetricScale(root);
|
|
117
|
+
if (factor !== 1) {
|
|
118
|
+
console.info(`[assetLibrary] 已把源尺寸换算成米烘进模型(系数 ${factor.toFixed(4)}),放置时缩放基线为 1 = 真实大小`);
|
|
119
|
+
}
|
|
78
120
|
return exportGLB(root, []).then(compressGLBDraco);
|
|
79
121
|
}
|
|
80
122
|
/** 动作入库:骨骼树克隆 + 单条动画,不含网格 */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Bone, BoxGeometry, Group, Mesh, MeshBasicMaterial, MeshStandardMaterial, Texture, } from 'three';
|
|
2
|
-
import { cloneBoneTree, exportModelGLB, stripInvalidTextures } from './exportGLB';
|
|
1
|
+
import { Bone, Box3, BoxGeometry, Group, Mesh, MeshBasicMaterial, MeshStandardMaterial, Texture, Vector3, } from 'three';
|
|
2
|
+
import { CENTIMETER_SOURCE_THRESHOLD, bakeMetricScale, cloneBoneTree, exportModelGLB, metricScaleFactor, stripInvalidTextures, } from './exportGLB';
|
|
3
3
|
describe('cloneBoneTree', () => {
|
|
4
4
|
it('只保留骨骼节点(名字/变换),剔除网格', () => {
|
|
5
5
|
const root = new Group();
|
|
@@ -70,4 +70,58 @@ describe('exportModelGLB', () => {
|
|
|
70
70
|
expect(glb.byteLength).toBeGreaterThan(0);
|
|
71
71
|
expect(mat.map).toBeNull();
|
|
72
72
|
});
|
|
73
|
+
it('入库时把厘米源换算成米烘进根节点缩放(放置时缩放基线才是 1)', async () => {
|
|
74
|
+
// 模拟 Mixamo FBX 厘米单位:180 单位高的人
|
|
75
|
+
const root = new Group();
|
|
76
|
+
root.add(new Mesh(new BoxGeometry(50, 180, 30), new MeshBasicMaterial()));
|
|
77
|
+
const glb = await exportModelGLB(root);
|
|
78
|
+
expect(glb.byteLength).toBeGreaterThan(0);
|
|
79
|
+
expect(root.scale.x).toBeCloseTo(0.01, 6);
|
|
80
|
+
expect(root.scale.y).toBe(root.scale.x);
|
|
81
|
+
expect(root.scale.z).toBe(root.scale.x);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('metricScaleFactor', () => {
|
|
85
|
+
it('厘米源(超过阈值)换算 0.01,米源不动', () => {
|
|
86
|
+
expect(metricScaleFactor(180)).toBe(0.01);
|
|
87
|
+
expect(metricScaleFactor(1.8)).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
it('阈值边界:恰好阈值按米,超过才按厘米', () => {
|
|
90
|
+
expect(metricScaleFactor(CENTIMETER_SOURCE_THRESHOLD)).toBe(1);
|
|
91
|
+
expect(metricScaleFactor(CENTIMETER_SOURCE_THRESHOLD + 0.01)).toBe(0.01);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('bakeMetricScale', () => {
|
|
95
|
+
it('厘米源烘成米制真实尺寸(180 高的人 → 1.8)', () => {
|
|
96
|
+
const root = new Group();
|
|
97
|
+
root.add(new Mesh(new BoxGeometry(50, 180, 30), new MeshBasicMaterial()));
|
|
98
|
+
expect(bakeMetricScale(root)).toBeCloseTo(0.01, 6);
|
|
99
|
+
expect(root.scale.x).toBeCloseTo(0.01, 6);
|
|
100
|
+
// 烘完最长边 = 1.8 场景单位(米),人高占比不变
|
|
101
|
+
const box = new Box3().setFromObject(root);
|
|
102
|
+
expect(box.getSize(new Vector3()).y).toBeCloseTo(1.8, 6);
|
|
103
|
+
});
|
|
104
|
+
it('米制模型不动(幂等,重烘已烘焙资产系数为 1)', () => {
|
|
105
|
+
const root = new Group();
|
|
106
|
+
root.add(new Mesh(new BoxGeometry(1.8, 1.8, 1.8), new MeshBasicMaterial()));
|
|
107
|
+
expect(bakeMetricScale(root)).toBe(1);
|
|
108
|
+
expect(root.scale.x).toBe(1);
|
|
109
|
+
});
|
|
110
|
+
it('空模型(无边几何时)返回 1 且不动缩放', () => {
|
|
111
|
+
const root = new Group();
|
|
112
|
+
expect(bakeMetricScale(root)).toBe(1);
|
|
113
|
+
expect(root.scale.x).toBe(1);
|
|
114
|
+
});
|
|
115
|
+
it('骨骼局部数据不受影响(动画轨道寻址的那层)', () => {
|
|
116
|
+
const root = new Group();
|
|
117
|
+
const hips = new Bone();
|
|
118
|
+
hips.name = 'Hips';
|
|
119
|
+
hips.position.set(0, 90, 0);
|
|
120
|
+
const mesh = new Mesh(new BoxGeometry(50, 180, 30), new MeshBasicMaterial());
|
|
121
|
+
hips.add(mesh);
|
|
122
|
+
root.add(hips);
|
|
123
|
+
bakeMetricScale(root);
|
|
124
|
+
expect(hips.position.y).toBe(90); // 只有根节点 scale 变了
|
|
125
|
+
expect(root.scale.x).toBeCloseTo(0.01, 6);
|
|
126
|
+
});
|
|
73
127
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 首屏就绪闸门(2026-10-22):聚合「工程就绪 + 首批场景资源加载落定」,
|
|
3
|
+
* 就绪前 CameraScene 盖加载遮罩——此前顶栏/轴向控制器等同步部件首帧就画,
|
|
4
|
+
* 模型和面板走各自异步(Suspense fallback=null / 库加载)settled 才补,
|
|
5
|
+
* 开场观感参差。闸门只闸首屏:落定一次后,后续放置/加载不再触发遮盖。
|
|
6
|
+
*
|
|
7
|
+
* 模块级单值通路(与 getLibrary/poseSession 同风格):CameraScene 创建并
|
|
8
|
+
* 安装;PlacementManager 各层经 getFirstScreenGate() 上报,免层层传 prop;
|
|
9
|
+
* 未安装时返回 no-op(测试或宿主绕过 CameraScene 直用 PlacementManager 不炸)。
|
|
10
|
+
*/
|
|
11
|
+
export interface FirstScreenGate {
|
|
12
|
+
/** 工程就绪(useProjectReady 变真时调;幂等) */
|
|
13
|
+
setProjectReady(): void;
|
|
14
|
+
/** 登记一个首屏要等的资源;已落定过的 key 不重复登记(清单重扫防回加) */
|
|
15
|
+
expect(key: string): void;
|
|
16
|
+
/** 上报一个资源落定(加载成功或失败都算;未登记的 key 忽略) */
|
|
17
|
+
settle(key: string): void;
|
|
18
|
+
/** 落定后 expect/settle 全是 no-op */
|
|
19
|
+
readonly fired: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare const FIRST_SCREEN_TIMEOUT_MS = 15000;
|
|
22
|
+
export declare function createFirstScreenGate(onReady: () => void, timeoutMs?: number, warn?: (msg: string) => void): FirstScreenGate;
|
|
23
|
+
/** CameraScene 装配期调用(必须在子树 effect 之前,见调用点注释) */
|
|
24
|
+
export declare function installFirstScreenGate(gate: FirstScreenGate): void;
|
|
25
|
+
export declare function getFirstScreenGate(): FirstScreenGate;
|
|
26
|
+
/** CameraScene 卸载/测试复位用 */
|
|
27
|
+
export declare function resetFirstScreenGate(): void;
|
|
28
|
+
//# sourceMappingURL=firstScreenGate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"firstScreenGate.d.ts","sourceRoot":"","sources":["../src/firstScreenGate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,WAAW,eAAe;IAC9B,oCAAoC;IACpC,eAAe,IAAI,IAAI,CAAA;IACvB,2CAA2C;IAC3C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,sCAAsC;IACtC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,iCAAiC;IACjC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CACxB;AAED,eAAO,MAAM,uBAAuB,QAAQ,CAAA;AAE5C,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,IAAI,EACnB,SAAS,GAAE,MAAgC,EAC3C,IAAI,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAmB,GACzC,eAAe,CA8CjB;AAYD,gDAAgD;AAChD,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,eAAe,GAAG,IAAI,CAElE;AAED,wBAAgB,kBAAkB,IAAI,eAAe,CAEpD;AAED,2BAA2B;AAC3B,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const FIRST_SCREEN_TIMEOUT_MS = 15000;
|
|
2
|
+
export function createFirstScreenGate(onReady, timeoutMs = FIRST_SCREEN_TIMEOUT_MS, warn = console.warn) {
|
|
3
|
+
const pending = new Set();
|
|
4
|
+
const settled = new Set();
|
|
5
|
+
let projectReady = false;
|
|
6
|
+
let fired = false;
|
|
7
|
+
// 兜底:某个资源悬挂(网络卡死等)不能让用户永远看遮罩,超时强制揭幕
|
|
8
|
+
const timer = setTimeout(() => {
|
|
9
|
+
warn(`[firstScreen] 首屏等待超过 ${timeoutMs}ms 强制揭幕(未落定:${[...pending].join(', ') || '无'};工程就绪:${projectReady})`);
|
|
10
|
+
fire();
|
|
11
|
+
}, timeoutMs);
|
|
12
|
+
// Node 的 Timeout 才有 unref(浏览器返回 number):防测试进程被计时器挂住
|
|
13
|
+
if (typeof timer === 'object' && typeof timer.unref === 'function') {
|
|
14
|
+
timer.unref();
|
|
15
|
+
}
|
|
16
|
+
function fire() {
|
|
17
|
+
if (fired)
|
|
18
|
+
return;
|
|
19
|
+
fired = true;
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
onReady();
|
|
22
|
+
}
|
|
23
|
+
function check() {
|
|
24
|
+
if (projectReady && pending.size === 0)
|
|
25
|
+
fire();
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
get fired() {
|
|
29
|
+
return fired;
|
|
30
|
+
},
|
|
31
|
+
setProjectReady() {
|
|
32
|
+
projectReady = true;
|
|
33
|
+
check();
|
|
34
|
+
},
|
|
35
|
+
expect(key) {
|
|
36
|
+
if (fired || settled.has(key))
|
|
37
|
+
return;
|
|
38
|
+
pending.add(key);
|
|
39
|
+
},
|
|
40
|
+
settle(key) {
|
|
41
|
+
if (fired || !pending.has(key))
|
|
42
|
+
return;
|
|
43
|
+
pending.delete(key);
|
|
44
|
+
settled.add(key);
|
|
45
|
+
check();
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** 未安装时的默认闸门:fired=true 的 no-op,上报全部吞掉 */
|
|
50
|
+
const noopGate = {
|
|
51
|
+
fired: true,
|
|
52
|
+
setProjectReady() { },
|
|
53
|
+
expect() { },
|
|
54
|
+
settle() { },
|
|
55
|
+
};
|
|
56
|
+
let current = noopGate;
|
|
57
|
+
/** CameraScene 装配期调用(必须在子树 effect 之前,见调用点注释) */
|
|
58
|
+
export function installFirstScreenGate(gate) {
|
|
59
|
+
current = gate;
|
|
60
|
+
}
|
|
61
|
+
export function getFirstScreenGate() {
|
|
62
|
+
return current;
|
|
63
|
+
}
|
|
64
|
+
/** CameraScene 卸载/测试复位用 */
|
|
65
|
+
export function resetFirstScreenGate() {
|
|
66
|
+
current = noopGate;
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"firstScreenGate.test.d.ts","sourceRoot":"","sources":["../src/firstScreenGate.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createFirstScreenGate } from './firstScreenGate';
|
|
2
|
+
describe('createFirstScreenGate', () => {
|
|
3
|
+
it('工程就绪且无可等资源 → 立即落定', () => {
|
|
4
|
+
const onReady = jest.fn();
|
|
5
|
+
const gate = createFirstScreenGate(onReady);
|
|
6
|
+
expect(onReady).not.toHaveBeenCalled();
|
|
7
|
+
gate.setProjectReady();
|
|
8
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
9
|
+
expect(gate.fired).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it('有待载资源:全部 settle 且工程就绪才落定(先载完后就绪也成立)', () => {
|
|
12
|
+
const onReady = jest.fn();
|
|
13
|
+
const gate = createFirstScreenGate(onReady);
|
|
14
|
+
gate.expect('a');
|
|
15
|
+
gate.expect('b');
|
|
16
|
+
gate.settle('a');
|
|
17
|
+
gate.settle('b');
|
|
18
|
+
expect(onReady).not.toHaveBeenCalled(); // 工程未就绪
|
|
19
|
+
gate.setProjectReady();
|
|
20
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
21
|
+
});
|
|
22
|
+
it('部分 settle 不落定', () => {
|
|
23
|
+
const onReady = jest.fn();
|
|
24
|
+
const gate = createFirstScreenGate(onReady);
|
|
25
|
+
gate.expect('a');
|
|
26
|
+
gate.expect('b');
|
|
27
|
+
gate.setProjectReady();
|
|
28
|
+
gate.settle('a');
|
|
29
|
+
expect(onReady).not.toHaveBeenCalled();
|
|
30
|
+
gate.settle('b');
|
|
31
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
32
|
+
});
|
|
33
|
+
it('未登记的 settle 被忽略,不影响计数', () => {
|
|
34
|
+
const onReady = jest.fn();
|
|
35
|
+
const gate = createFirstScreenGate(onReady);
|
|
36
|
+
gate.expect('a');
|
|
37
|
+
gate.settle('陌生人');
|
|
38
|
+
gate.setProjectReady();
|
|
39
|
+
expect(onReady).not.toHaveBeenCalled();
|
|
40
|
+
gate.settle('a');
|
|
41
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
42
|
+
});
|
|
43
|
+
it('已落定的 key 再 expect 不回加(放置清单重扫场景)', () => {
|
|
44
|
+
const onReady = jest.fn();
|
|
45
|
+
const gate = createFirstScreenGate(onReady);
|
|
46
|
+
gate.expect('a');
|
|
47
|
+
gate.setProjectReady();
|
|
48
|
+
gate.settle('a');
|
|
49
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
50
|
+
// 清单重扫又把 a 登记一遍 + 新条目 b —— 闸门已落定,全部忽略
|
|
51
|
+
gate.expect('a');
|
|
52
|
+
gate.expect('b');
|
|
53
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
54
|
+
});
|
|
55
|
+
it('落定只触发一次(setProjectReady/settle 重复调用幂等)', () => {
|
|
56
|
+
const onReady = jest.fn();
|
|
57
|
+
const gate = createFirstScreenGate(onReady);
|
|
58
|
+
gate.setProjectReady();
|
|
59
|
+
gate.setProjectReady();
|
|
60
|
+
gate.settle('从未登记');
|
|
61
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
it('超时强制揭幕并告警,之后不再重复触发', () => {
|
|
64
|
+
jest.useFakeTimers();
|
|
65
|
+
try {
|
|
66
|
+
const onReady = jest.fn();
|
|
67
|
+
const warn = jest.fn();
|
|
68
|
+
const gate = createFirstScreenGate(onReady, 5000, warn);
|
|
69
|
+
gate.expect('卡住的模型');
|
|
70
|
+
jest.advanceTimersByTime(5000);
|
|
71
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(warn.mock.calls[0][0]).toContain('卡住的模型');
|
|
73
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
74
|
+
// 超时后迟到的 settle 不再触发
|
|
75
|
+
gate.settle('卡住的模型');
|
|
76
|
+
jest.advanceTimersByTime(10000);
|
|
77
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
jest.useRealTimers();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreamerbird/camera-studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -18,11 +18,6 @@
|
|
|
18
18
|
"dist/**/*",
|
|
19
19
|
"assets/**/*"
|
|
20
20
|
],
|
|
21
|
-
"scripts": {
|
|
22
|
-
"build:ts": "tsc -b tsconfig.json",
|
|
23
|
-
"build:api": "tsc -b tsconfig.json && api-extractor run --local --config devEnv/api-extractor.json",
|
|
24
|
-
"clean": "rimraf dist tsconfig.tsbuildinfo"
|
|
25
|
-
},
|
|
26
21
|
"peerDependencies": {
|
|
27
22
|
"@dreamerbird/core": "*",
|
|
28
23
|
"@dreamerbird/studio": "*",
|
|
@@ -32,9 +27,6 @@
|
|
|
32
27
|
"three": ">=0.185"
|
|
33
28
|
},
|
|
34
29
|
"dependencies": {
|
|
35
|
-
"@dreamerbird/dataverse": "1.0.0",
|
|
36
|
-
"@dreamerbird/r3f": "1.0.0",
|
|
37
|
-
"@dreamerbird/react": "1.0.0",
|
|
38
30
|
"@dreamerbird/threeik": "0.2.1",
|
|
39
31
|
"@gltf-transform/core": "^4.0.0",
|
|
40
32
|
"@gltf-transform/extensions": "^4.0.0",
|
|
@@ -43,7 +35,10 @@
|
|
|
43
35
|
"@react-three/drei": "^10.7.8",
|
|
44
36
|
"draco3dgltf": "^1.5.7",
|
|
45
37
|
"styled-components": "^6.5.3",
|
|
46
|
-
"three-stdlib": "^2.36.1"
|
|
38
|
+
"three-stdlib": "^2.36.1",
|
|
39
|
+
"@dreamerbird/dataverse": "1.0.0",
|
|
40
|
+
"@dreamerbird/r3f": "1.0.0",
|
|
41
|
+
"@dreamerbird/react": "1.0.0"
|
|
47
42
|
},
|
|
48
43
|
"devDependencies": {
|
|
49
44
|
"@microsoft/api-extractor": "^7.28.6",
|
|
@@ -58,5 +53,10 @@
|
|
|
58
53
|
"react-dom": "^19.2.0",
|
|
59
54
|
"three": "^0.185.1",
|
|
60
55
|
"typescript": "5.6.3"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build:ts": "tsc -b tsconfig.json",
|
|
59
|
+
"build:api": "tsc -b tsconfig.json && api-extractor run --local --config devEnv/api-extractor.json",
|
|
60
|
+
"clean": "rimraf dist tsconfig.tsbuildinfo"
|
|
61
61
|
}
|
|
62
62
|
}
|