@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.
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;AA4kB9C;;;;;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,qBAw/CxE;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"}
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"}
@@ -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, 0, 16];
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(Model, { url: sceneGLB, selectLocked: !!placing })),
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":"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"}
1
+ {"version":3,"file":"PlacementManager.d.ts","sourceRoot":"","sources":["../src/PlacementManager.tsx"],"names":[],"mappings":"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"}
@@ -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
- /** 归一化:任意 FBX 单位差异很大,统一缩放到最大边 5 个世界单位,底部对齐落点 */
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 ? 5 / maxDim : 1,
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,EAAO,KAAK,EAAC,MAAM,OAAO,CAAA;AACjC,OAAO,KAAK,EAAC,aAAa,EAAQ,QAAQ,EAAU,MAAM,OAAO,CAAA;AAIjE;;;;;;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,CAQnE;AAED,6BAA6B;AAC7B,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,QAAQ,EACd,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAEtB"}
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
  });
@@ -3,7 +3,7 @@ export const seedAssetsManifest = {
3
3
  {
4
4
  key: 'y-bot',
5
5
  file: 'Y Bot.glb',
6
- contentHash: '95cbcdd2192dbe68',
6
+ contentHash: 'fc55d96ed9b27445',
7
7
  name: 'Y Bot',
8
8
  rig: 'rig-16a71e249f45355b',
9
9
  boneCount: 117,
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=firstScreenGate.test.d.ts.map
@@ -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.1.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
  }