適用:要一個真的立體、可以轉來轉去看的模型——抽血或注射位置、器材構造、病房或動線的立體示意。
模型上放幾個標記,點了出現說明。不存資料。只是平面的圖加說明,用 content-site 就好。
先講清楚兩件事
- Unity、Godot、Unreal 匯出的網頁版,和任何 WebAssembly(`.wasm`)都不能用。平台的安全設定不允許 WebAssembly,放上去一定跑不起來。
使用者問「可以用 Unity 嗎」,直接說不行與原因,再說這份的做法(2026-10-13 實跑:AI 助手三次說可以,是錯的)。
- 不要用 CSS 轉一張圖假裝 3D。轉到側面會變成一條線,使用者一眼就看出來(同一次實跑)。用 three.js 畫真的立體模型。
平台給的東西
- three.js 0.186.1 在平台的書架上:核心
three.module.js,加上examples/jsm/controls/OrbitControls.js(轉動、縮放、平移)。
只有 ES module,要寫 import map(下面骨架有)。不要從 CDN 載入,也不要把它複製進應用資料夾。
- 書架沒有模型載入器(GLTFLoader 等)。模型用基本形狀組起來:圓柱
CylinderGeometry、球SphereGeometry、膠囊CapsuleGeometry、方塊BoxGeometry。
教學用的示意模型這樣就夠,也比較好調。真的要載入 .glb:把載入器的檔案下載進應用資料夾,不要用 Draco 壓縮(它的解碼器開背景執行緒的方式會被擋)。
- 整個資料夾壓縮後 50 MB 以內。
骨架
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>抽血位置 3D 教學|檢驗科</title>
<style>
body { margin: 0; font-family: system-ui, sans-serif; }
.wrap { display: grid; grid-template-columns: 1fr 320px; min-height: 100vh; }
#view { position: relative; } #view canvas { display: block; width: 100%; height: 100%; touch-action: none; }
aside { padding: 16px; border-left: 1px solid #ddd; }
@media (max-width: 760px) { .wrap { grid-template-columns: 1fr; grid-template-rows: 60vh auto; } aside { border-left: 0; } }
.pick button { display: block; width: 100%; margin: 6px 0; padding: 10px; font-size: 16px; }
</style>
<script type="importmap">{"imports":{"three":"https://platform.wanfangai.tw/lib/three@0.186.1/three.module.js","three/addons/":"https://platform.wanfangai.tw/lib/three@0.186.1/examples/jsm/"}}</script>
</head>
<body>
<div class="wrap">
<div id="view"></div>
<aside>
<h1>抽血位置 3D 教學</h1>
<p>用滑鼠拖曳(手機用單指)轉動手臂,滾輪或雙指縮放。點紅點或下面的按鈕看說明。</p>
<div class="pick" id="pick"></div>
<section id="info" aria-live="polite"><p>請選一個位置。</p></section>
<p><small>教學示意,不取代院內訓練與 SOP。審閱單位:(請填)|最後審閱:(請填)</small></p>
</aside>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const view = document.getElementById('view');
const scene = new THREE.Scene(); scene.background = new THREE.Color('#eef4f7');
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100); camera.position.set(0, 0.5, 10);
const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
view.appendChild(renderer.domElement);
scene.add(new THREE.HemisphereLight('#ffffff', '#99aabb', 1.2));
const sun = new THREE.DirectionalLight('#ffffff', 1.5); sun.position.set(3, 5, 4); scene.add(sun);
// The model is one group: everything put in it (the markers too) turns with it.
const arm = new THREE.Group(); scene.add(arm);
const skin = new THREE.MeshStandardMaterial({ color: '#e8b49a', roughness: 0.7 });
const upper = new THREE.Mesh(new THREE.CapsuleGeometry(0.42, 2.0, 8, 24), skin); upper.position.y = 1.4; arm.add(upper);
const fore = new THREE.Mesh(new THREE.CapsuleGeometry(0.36, 2.0, 8, 24), skin); fore.position.y = -1.0; arm.add(fore);
const SPOTS = [
{ id: 'median', name: '肘正中靜脈', pos: [0, 0.25, 0.43], text: '肘窩正中,最常用的位置……(內容請由檢驗科提供)' },
{ id: 'cephalic', name: '頭靜脈', pos: [0.32, -0.1, 0.3], text: '前臂拇指側……' },
{ id: 'basilic', name: '貴要靜脈', pos: [-0.32, -0.1, 0.3], text: '前臂小指側,靠近神經與動脈,要小心……' },
];
const red = new THREE.MeshStandardMaterial({ color: '#d62828', emissive: '#550000' });
const markers = SPOTS.map((s) => {
const m = new THREE.Mesh(new THREE.SphereGeometry(0.09, 24, 16), red.clone());
m.position.set(...s.pos); m.userData = s; arm.add(m); return m;
});
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.minDistance = 3; controls.maxDistance = 16;
const info = document.getElementById('info');
function select(spot) {
for (const m of markers) m.material.color.set(m.userData === spot ? '#f4a261' : '#d62828');
info.innerHTML = `<h2>${spot.name}</h2><p>${spot.text}</p>`;
}
const pick = document.getElementById('pick');
for (const s of SPOTS) {
const b = document.createElement('button'); b.textContent = s.name; b.onclick = () => select(s); pick.appendChild(b);
}
// Click or tap a marker (a drag that turns the model is not a click).
const ray = new THREE.Raycaster(); const at = new THREE.Vector2(); let down = null;
renderer.domElement.addEventListener('pointerdown', (e) => { down = [e.clientX, e.clientY]; });
renderer.domElement.addEventListener('pointerup', (e) => {
if (!down || Math.hypot(e.clientX - down[0], e.clientY - down[1]) > 5) return;
const r = renderer.domElement.getBoundingClientRect();
at.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
ray.setFromCamera(at, camera);
const hit = ray.intersectObjects(markers)[0]; if (hit) select(hit.object.userData);
});
function resize() {
const w = view.clientWidth, h = view.clientHeight || 400;
renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix();
}
addEventListener('resize', resize); resize();
renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
</script>
</body>
</html>- 標記一定要加進模型的群組(
arm.add(m)),模型轉的時候才會跟著轉。 - 每個標記都另外有一顆按鈕:點不準小紅點的人、用鍵盤或報讀軟體的人也選得到。
- 說明文字由使用者提供,你只排版;頁尾寫審閱單位與日期(同
content-site)。
說「做好了」之前
- 打開試用版:瀏覽器主控台沒有紅字;拖曳時模型真的是立體的(轉到側面看得到厚度,不是一條線);標記跟著模型轉;點標記與按鈕都會出現說明;手機上單指能轉、雙指能縮放。
- 確認不了的,不要說它已經可以用,改成請使用者打開確認哪幾項。