Day 11、12 是 viewer 的骨架與互動。這篇講熱點怎麼算出來,以及一次真實的品質問題與修復。
{
"id": "hs_03", "title": "Motor",
"anchor": { "type": "part", "part_id": "p_0018" },
"camera": { "target_mm": [-40,-130,82], "distance_mm": 264.2, "azimuth_deg": 246.0, "elevation_deg": 20 },
"order": 3
}
anchor 決定「錨在哪」(part 用零件 id,point 直接給座標);camera 決定「飛過去站哪看」,選填——沒有就只彈卡片不飛鏡頭。由 packs/exhibit(場景包)依零件體積由大到小挑選、產生,viewer 只負責照著飛,不參與判斷「哪個零件重要」。
每個欄位的意義與限制:
| 欄位 | 型別 / 限制 | 說明 |
|---|---|---|
id |
字串,同一份場景內唯一 | 目前產生規則是 hs_01、hs_02…按 order 編號 |
title |
字串,選填 | 沒有 GOOGLE_API_KEY 時目前只有零件名稱,沒有文案(Day 22 才會補) |
body |
字串,選填 | 熱點卡片的說明文字,同樣待 curator agent 補 |
anchor.type |
"part" 或 "point" |
二選一,決定下面要哪個欄位 |
anchor.part_id |
零件 id(如 p_0018),type="part" 時必填 |
必須是這個資產裡真的存在的零件 id,也必須等於 GLB 節點名(C0-m1 之後這兩者定義為同一件事) |
anchor.position_mm |
[x,y,z],type="point" 時必填 |
資產座標(Z-up、mm),不需要對應任何零件 |
camera |
物件,選填 | 沒有就只彈卡片、鏡頭不動(Day 11 提過的 point 熱點範例) |
camera.target_mm |
[x,y,z] |
鏡頭要看向哪裡 |
camera.distance_mm |
數字 > 0 | 鏡頭離目標多遠 |
camera.azimuth_deg / elevation_deg |
角度 | 跟 scene.camera.initial 用同一套球座標定義(Day 11 的 sphericalPosition) |
order |
整數 | 決定側欄/自動導覽(scene.tour.steps)播放的順序 |
① 建置時(Python,算出鏡頭角度寫進場景)
packs/exhibit/pack_exhibit/pack.py
build_scene(ctx) # 場景包組裝整份 scene.json
└─ suggest_hotspots(ctx, max_count=8, fov_deg)
└─ suggest_hotspots(asset, max_count, fov_deg) # 模組層函式,純資料運算
├─ 從 asset.semantics.parts 篩出可選取、非組裝的葉零件,依體積排序取前 8
└─ 對每個入選零件呼叫 part_camera(part, fov_deg, others=, assembly_center=, assembly_diag=)
→ 回傳 {target_mm, distance_mm, azimuth_deg, elevation_deg} 或 None
新版之後 part_camera 還是這個名字,只是多了三個參數(others/assembly_center/assembly_diag),沒有這三個參數時行為等同舊版(固定 35°/20°),所以既有測試不用改。
② 執行時(TypeScript,讀場景播放熱點)
使用者點畫面上的標記 或 呼叫 Viewer.goToHotspot(id) 或 自動導覽 tour 呼叫 goToHotspot(id)
└─ hotspotNavigator.activate(hotspot) # packages/viewer-core/src/index.ts
├─ 有 title/body → showCard() 彈文字卡片
├─ 有 camera → flyCameraTo(hotspot.camera) # 用跟 reset() 同一套 sphericalPosition + convertPoint
└─ anchor.type === "part" → selectById(part_id) # V1-m7:同時高亮該零件(Day 12 那套機制)
每一幀(tick)另外跑:
updateHotspotMarkers()
├─ refreshOcclusion():對每個熱點做相機→錨點的 raycast,判斷有沒有被擋住
└─ 依 occlusionStyle(hotspot_occlusion 模式, 是否被擋) 決定這個標記要不要顯示、透明度、要不要加虛線環
也就是說,「算鏡頭角度」發生在建置時(Python,寫死進 scene.json),「飛過去、判斷遮擋」發生在執行時(TypeScript,每次都重新算)——兩邊完全獨立,viewer 不會重算鏡頭角度,只負責照著飛跟判斷擋不擋。
舊版 part_camera() 只用零件自己的包圍盒算距離,方位角、仰角是固定值(35°/20°),對所有零件一視同仁:
distance = radius * _HOTSPOT_FIT_MARGIN / math.sin(math.radians(fov_deg) / 2)
return {"target_mm": center, "distance_mm": distance, "azimuth_deg": 35.0, "elevation_deg": 20.0}
問題:完全不知道其他零件在哪裡。實機測試時,8 個熱點裡有 2 個算出的相機位置有問題——其中 Motor_Flange 那顆,相機座標直接落在 Housing_Body 的包圍盒範圍內(外殼壁厚只有 10mm,相機卡在壁裡)。
新版拿到其他零件的包圍盒與整體零件聯集的中心,先算一個「基準方位角」(零件中心相對整體中心的水平方向),再以 15° 為步進掃 ±180°、搭配三種仰角(20/35/50°)當候選:
修前後對照(方位角、mm):
| 熱點 | 零件 | 修前相機位置 | 修後相機位置 | 備註 |
|---|---|---|---|---|
| hs_03 | Motor | (163, 12, 172) | 方位角 246°,(-141, -357, 172) | 修前貼著機殼 |
| hs_07 | Motor_Flange | (108, 39, 148) | 方位角 182°,(-221, -71, 148) | 修前落在 Housing_Body 包圍盒內 |
修完後 8 個熱點全部第一輪(1.0× 距離)就找到合格角度,沒有用到保底的拉遠或抬仰角。
AABB 是什麼:用一個「邊都跟座標軸平行」的長方體,把一個零件的形狀整個包起來,只記兩個角——min(最小的 x/y/z)跟 max(最大的 x/y/z),例如 Housing_Body 的 bounds_mm 是 {"min":[-110,-60,12], "max":[110,60,152]}。這是 3D 圖學裡最基本、最便宜的近似形狀,好處是判斷「點在不在裡面」「兩個盒子有沒有交集」都只是幾個數字比大小,不用管零件實際的曲面長什麼樣:
_inside,判斷相機位置合不合格):每個軸的座標都要落在 min[i] 到 max[i] 之間,三個軸都成立才算在裡面。_segment_crosses,判斷視線有沒有被擋,用的是「slab 法」):把盒子想成三對互相平行的牆(x 方向一對、y 方向一對、z 方向一對),算線段跟每一對牆的交會範圍,三個範圍取交集,交集不是空的就代表線段真的穿過了這個盒子。代價就是前面提到的:中空的機殼會被當成實心,因為 AABB 只看「最外圍的邊界」,不管裡面是不是挖空的,所以判斷「視線有沒有被擋」時,會保守地把機殼內部也算進去。
camera.limits.max_distance_mm 比對過,目前範例資料的鏡頭距離都在安全範圍內。即使演算法已經避開「卡進去」,還是有零件天生藏在別的零件裡面,視線一定被擋。這種情況由 interaction.hotspot_occlusion 決定怎麼顯示:
| 模式 | 效果 |
|---|---|
xray(預設) |
標記保留但降透明度、加虛線環;配合遮擋物半透明疊層一起顯示 |
fade |
標記淡出(更低透明度),沒有虛線環 |
hide |
標記完全隱藏 |
判定方式是從相機往錨點射一條線,檢查最近的命中是不是屬於目標零件本身;距離很近的多個標記還會互相偵測、自動讓開避免重疊。
爆炸的位移計算已經實作:讀每個零件的爆炸方向向量,乘上使用者給的爆炸程度與場景設定的最大距離,回傳一份「零件 id → 位移量」的清單。但目前沒有任何地方消費這份清單——viewer 端還沒有讀取並套用這個指令的邏輯,所以爆炸圖現在還看不到任何視覺效果。這是清楚標記、留給後續的缺口,不是這篇要交代完的功能。

修好之後:Motor 熱點的鏡頭

修好之後:Motor_Flange 熱點不再卡進機殼牆裡
下一篇:離線展場播放包。