iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Build on Google AI

AI 策展人:用 Google ADK 打造會思考、會介紹的 3D 展示平台系列 第 13 篇

Day 13|熱點鏡頭品質:從固定角度到避開遮擋,以及爆炸圖的現況

  • 分享至 

  • xImage
  •  

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°)當候選:

  1. 硬規則:相機位置落在任何其他葉零件的包圍盒內 → 直接淘汰
  2. 軟規則:候選之間比「視線(相機到目標的線段)穿過幾個其他零件的包圍盒」,數字小的優先;同分再看離基準角度近不近
  3. 保底:所有候選都不理想 → 距離依序拉到 1.25×、1.5×……最多 3×;仍不行則在 3× 距離把仰角抬到 70°

修前後對照(方位角、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,Axis-Aligned Bounding Box,軸對齊包圍盒)計算,不做真正的幾何遮擋運算,也不知道自己在服務哪個場景——輸入只是一堆零件的長方體座標與目標零件,輸出一個不會卡進其他長方體的角度。

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 只看「最外圍的邊界」,不管裡面是不是挖空的,所以判斷「視線有沒有被擋」時,會保守地把機殼內部也算進去。

  • 包圍盒把中空的機殼當成實心處理,所以殼內的零件(齒輪、軸承)即使殼上有開口,視線判定上仍算被擋住——這是保守估計,不是精確判斷。
  • 拉遠的保底上限(3×)沒有跟 viewer 端的 camera.limits.max_distance_mm 比對過,目前範例資料的鏡頭距離都在安全範圍內。

被遮擋的熱點:三種顯示模式

即使演算法已經避開「卡進去」,還是有零件天生藏在別的零件裡面,視線一定被擋。這種情況由 interaction.hotspot_occlusion 決定怎麼顯示:

模式 效果
xray(預設) 標記保留但降透明度、加虛線環;配合遮擋物半透明疊層一起顯示
fade 標記淡出(更低透明度),沒有虛線環
hide 標記完全隱藏

判定方式是從相機往錨點射一條線,檢查最近的命中是不是屬於目標零件本身;距離很近的多個標記還會互相偵測、自動讓開避免重疊。

爆炸圖:目前只有「算」,還沒有「動」

爆炸的位移計算已經實作:讀每個零件的爆炸方向向量,乘上使用者給的爆炸程度與場景設定的最大距離,回傳一份「零件 id → 位移量」的清單。但目前沒有任何地方消費這份清單——viewer 端還沒有讀取並套用這個指令的邏輯,所以爆炸圖現在還看不到任何視覺效果。這是清楚標記、留給後續的缺口,不是這篇要交代完的功能。

https://ithelp.ithome.com.tw/upload/images/20260927/20184312A4c2JdnMpy.png
修好之後:Motor 熱點的鏡頭

https://ithelp.ithome.com.tw/upload/images/20260927/20184312zSUcdtxIKa.png
修好之後:Motor_Flange 熱點不再卡進機殼牆裡

下一篇:離線展場播放包。


上一篇
Day 12|自己蓋展示櫃(二):點得到零件——Raycast、高亮、零件列表
下一篇
Day 14|斷網也能播:把展示櫃裝進一只行李箱(`exhibit-kiosk`)
系列文
AI 策展人:用 Google ADK 打造會思考、會介紹的 3D 展示平台 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言