33< head >
44 < meta charset ="UTF-8 " />
55 < meta name ="viewport " content ="width=device-width, initial-scale=1.0 " />
6- < title > MediaPipe + Three.js Shape Creator</ title >
7- < style >
8- body , html {
9- margin : 0 ;
10- padding : 0 ;
11- overflow : hidden;
12- background : # 000 ;
13- }
14- # webcam , # canvas , # three-canvas {
15- position : absolute;
16- width : 100% ;
17- height : 100% ;
18- top : 0 ;
19- left : 0 ;
20- object-fit : cover;
21- pointer-events : none;
22- transform : scaleX (-1 );
23- }
24- # recycle-bin {
25- position : absolute;
26- bottom : 60px ;
27- right : 60px ;
28- width : 160px ;
29- height : 160px ;
30- z-index : 20 ;
31- pointer-events : none;
32- }
33- # recycle-bin .active {
34- filter : drop-shadow (0 0 10px # ff0000 );
35- transform : scale (1.1 );
36- transition : transform 0.2s , filter 0.2s ;
37- }
38- # instructions {
39- position : absolute;
40- top : 5px ;
41- left : 5px ;
42- color : white;
43- background : rgba (0 , 0 , 0 , 0.5 );
44- padding : 10px 15px ;
45- /* border-radius: 10px; */
46- font-family : sans-serif;
47- font-size : 14px ;
48- z-index : 30 ;
49- }
50- </ style >
6+ < title > MediaPipe / Three.js Shape Creator</ title >
7+ < script defer src ="https://cloud.umami.is/script.js " data-website-id ="eb59c81c-27cb-4e1d-9e8c-bfbe70c48cd9 "> </ script >
8+ < link rel ="stylesheet " href ="styles.css ">
519</ head >
5210< body >
11+
5312 < video id ="webcam " autoplay muted playsinline > </ video >
5413 < canvas id ="canvas "> </ canvas >
5514 < div id ="three-canvas "> </ div >
5615 < img id ="recycle-bin " src ="recyclebin.png " alt ="Recycle Bin " />
5716 < div id ="instructions ">
5817 Bring hands close and pinch to create a shape< br >
59- Move hands apart (while pinching) to make the shape larger< br >
60- Hover over a shape and pinch to move it< br >
18+ > Move hands apart to make the shape larger< br >
19+ Hover over a shape / pinch to move it< br >
6120 Move a shape into the recycle bin to delete it
6221 </ div >
22+ < p id ="links-para "> < a href ="https://x.com/measure_plan " target ="_blank "> Twitter</ a > | < a href ="https://www.instagram.com/stereo.drift/ " target ="_blank "> Instagram</ a > | < a href ="https://github.com/collidingScopes/threejs-handtracking-101 " target ="_blank "> Source Code</ a > | < a href ="https://buymeacoffee.com/stereodrift " target ="_blank "> ❤️</ a > </ p >
6323
6424 < script src ="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js "> </ script >
6525 < script src ="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js "> </ script >
6626 < script src ="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js "> </ script >
67- < script >
68- let video = document . getElementById ( 'webcam' ) ;
69- let canvas = document . getElementById ( 'canvas' ) ;
70- let ctx = canvas . getContext ( '2d' ) ;
71- let scene , camera , renderer ;
72- let shapes = [ ] ;
73- let currentShape = null ;
74- let isPinching = false ;
75- let shapeScale = 1 ;
76- let originalDistance = null ;
77- let selectedShape = null ;
78- let shapeCreatedThisPinch = false ;
79- let lastShapeCreationTime = 0 ;
80- const shapeCreationCooldown = 1000 ;
81-
82- const initThree = ( ) => {
83- scene = new THREE . Scene ( ) ;
84- camera = new THREE . PerspectiveCamera ( 75 , window . innerWidth / window . innerHeight , 0.1 , 1000 ) ;
85- camera . position . z = 5 ;
86- renderer = new THREE . WebGLRenderer ( { alpha : true } ) ;
87- renderer . setSize ( window . innerWidth , window . innerHeight ) ;
88- document . getElementById ( 'three-canvas' ) . appendChild ( renderer . domElement ) ;
89- const light = new THREE . AmbientLight ( 0xffffff , 1 ) ;
90- scene . add ( light ) ;
91- animate ( ) ;
92- } ;
93-
94- const animate = ( ) => {
95- requestAnimationFrame ( animate ) ;
96- shapes . forEach ( shape => {
97- if ( shape !== selectedShape ) {
98- shape . rotation . x += 0.01 ;
99- shape . rotation . y += 0.01 ;
100- }
101- } ) ;
102- renderer . render ( scene , camera ) ;
103- } ;
104-
105- const neonColors = [ 0xFF00FF , 0x00FFFF , 0xFF3300 , 0x39FF14 , 0xFF0099 , 0x00FF00 , 0xFF6600 , 0xFFFF00 ] ;
106- let colorIndex = 0 ;
107-
108- const getNextNeonColor = ( ) => {
109- const color = neonColors [ colorIndex ] ;
110- colorIndex = ( colorIndex + 1 ) % neonColors . length ;
111- return color ;
112- } ;
113-
114- const createRandomShape = ( position ) => {
115- const geometries = [
116- new THREE . BoxGeometry ( ) ,
117- new THREE . SphereGeometry ( 0.5 , 32 , 32 ) ,
118- new THREE . ConeGeometry ( 0.5 , 1 , 32 ) ,
119- new THREE . CylinderGeometry ( 0.5 , 0.5 , 1 , 32 )
120- ] ;
121- const geometry = geometries [ Math . floor ( Math . random ( ) * geometries . length ) ] ;
122- const color = getNextNeonColor ( ) ;
123- const group = new THREE . Group ( ) ;
124-
125- const material = new THREE . MeshBasicMaterial ( { color : color , transparent : true , opacity : 0.5 } ) ;
126- const fillMesh = new THREE . Mesh ( geometry , material ) ;
127-
128- const wireframeMaterial = new THREE . MeshBasicMaterial ( { color : 0xffffff , wireframe : true } ) ;
129- const wireframeMesh = new THREE . Mesh ( geometry , wireframeMaterial ) ;
130-
131- group . add ( fillMesh ) ;
132- group . add ( wireframeMesh ) ;
133- group . position . copy ( position ) ;
134- scene . add ( group ) ;
135-
136- shapes . push ( group ) ;
137- return group ;
138- } ;
139-
140- const get3DCoords = ( normX , normY ) => {
141- const x = ( normX - 0.5 ) * 10 ;
142- const y = ( 0.5 - normY ) * 10 ;
143- return new THREE . Vector3 ( x , y , 0 ) ;
144- } ;
145-
146- const isPinch = ( landmarks ) => {
147- const d = ( a , b ) => Math . hypot ( a . x - b . x , a . y - b . y , a . z - b . z ) ;
148- return d ( landmarks [ 4 ] , landmarks [ 8 ] ) < 0.06 ;
149- } ;
150-
151- const areIndexFingersClose = ( l , r ) => {
152- const d = ( a , b ) => Math . hypot ( a . x - b . x , a . y - b . y ) ;
153- return d ( l [ 8 ] , r [ 8 ] ) < 0.12 ;
154- } ;
155-
156- const findNearestShape = ( position ) => {
157- let minDist = Infinity ;
158- let closest = null ;
159- shapes . forEach ( shape => {
160- const dist = shape . position . distanceTo ( position ) ;
161- if ( dist < 1.5 && dist < minDist ) {
162- minDist = dist ;
163- closest = shape ;
164- }
165- } ) ;
166- return closest ;
167- } ;
168-
169- const isInRecycleBinZone = ( position ) => {
170- const vector = position . clone ( ) . project ( camera ) ;
171- const screenX = ( ( vector . x + 1 ) / 2 ) * window . innerWidth ;
172- const screenY = ( ( - vector . y + 1 ) / 2 ) * window . innerHeight ;
17327
174- const binWidth = 160 ;
175- const binHeight = 160 ;
176- const binLeft = window . innerWidth - 60 - binWidth ;
177- const binTop = window . innerHeight - 60 - binHeight ;
178- const binRight = binLeft + binWidth ;
179- const binBottom = binTop + binHeight ;
180-
181- const adjustedX = window . innerWidth - screenX ;
182-
183- return adjustedX >= binLeft && adjustedX <= binRight && screenY >= binTop && screenY <= binBottom ;
184- } ;
185-
186- const hands = new Hands ( { locateFile : file => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${ file } ` } ) ;
187- hands . setOptions ( { maxNumHands : 2 , modelComplexity : 1 , minDetectionConfidence : 0.7 , minTrackingConfidence : 0.7 } ) ;
188-
189- hands . onResults ( results => {
190- ctx . clearRect ( 0 , 0 , canvas . width , canvas . height ) ;
191- const recycleBin = document . getElementById ( 'recycle-bin' ) ;
192-
193- for ( const landmarks of results . multiHandLandmarks ) {
194- const drawCircle = ( landmark ) => {
195- ctx . beginPath ( ) ;
196- ctx . arc ( landmark . x * canvas . width , landmark . y * canvas . height , 10 , 0 , 2 * Math . PI ) ;
197- ctx . fillStyle = 'rgba(0, 255, 255, 0.7)' ;
198- ctx . fill ( ) ;
199- } ;
200- drawCircle ( landmarks [ 4 ] ) ; // Thumb tip
201- drawCircle ( landmarks [ 8 ] ) ; // Index tip
202- }
203-
204- // Existing shape interaction and gesture logic...
205- if ( results . multiHandLandmarks . length === 2 ) {
206- const [ l , r ] = results . multiHandLandmarks ;
207- const leftPinch = isPinch ( l ) ;
208- const rightPinch = isPinch ( r ) ;
209- const indexesClose = areIndexFingersClose ( l , r ) ;
210-
211- if ( leftPinch && rightPinch ) {
212- const left = l [ 8 ] ;
213- const right = r [ 8 ] ;
214- const centerX = ( left . x + right . x ) / 2 ;
215- const centerY = ( left . y + right . y ) / 2 ;
216- const distance = Math . hypot ( left . x - right . x , left . y - right . y ) ;
217-
218- if ( ! isPinching ) {
219- const now = Date . now ( ) ;
220- if ( ! shapeCreatedThisPinch && indexesClose && now - lastShapeCreationTime > shapeCreationCooldown ) {
221- currentShape = createRandomShape ( get3DCoords ( centerX , centerY ) ) ;
222- lastShapeCreationTime = now ;
223- shapeCreatedThisPinch = true ;
224- originalDistance = distance ;
225- }
226- } else if ( currentShape && originalDistance ) {
227- shapeScale = distance / originalDistance ;
228- currentShape . scale . set ( shapeScale , shapeScale , shapeScale ) ;
229- }
230- isPinching = true ;
231- recycleBin . classList . remove ( 'active' ) ;
232- return ;
233- }
234- }
235-
236- isPinching = false ;
237- shapeCreatedThisPinch = false ;
238- originalDistance = null ;
239- currentShape = null ;
240-
241- if ( results . multiHandLandmarks . length > 0 ) {
242- for ( const landmarks of results . multiHandLandmarks ) {
243- const indexTip = landmarks [ 8 ] ;
244- const position = get3DCoords ( indexTip . x , indexTip . y ) ;
245-
246- if ( isPinch ( landmarks ) ) {
247- if ( ! selectedShape ) {
248- selectedShape = findNearestShape ( position ) ;
249- }
250- if ( selectedShape ) {
251- selectedShape . position . copy ( position ) ;
252-
253- const inBin = isInRecycleBinZone ( selectedShape . position ) ;
254- selectedShape . children . forEach ( child => {
255- if ( child . material && child . material . wireframe ) {
256- child . material . color . set ( inBin ? 0xff0000 : 0xffffff ) ;
257- }
258- } ) ;
259- if ( inBin ) {
260- recycleBin . classList . add ( 'active' ) ;
261- } else {
262- recycleBin . classList . remove ( 'active' ) ;
263- }
264- }
265- } else {
266- if ( selectedShape && isInRecycleBinZone ( selectedShape . position ) ) {
267- scene . remove ( selectedShape ) ;
268- shapes = shapes . filter ( s => s !== selectedShape ) ;
269- }
270- selectedShape = null ;
271- recycleBin . classList . remove ( 'active' ) ;
272- }
273- }
274- } else {
275- if ( selectedShape && isInRecycleBinZone ( selectedShape . position ) ) {
276- scene . remove ( selectedShape ) ;
277- shapes = shapes . filter ( s => s !== selectedShape ) ;
278- }
279- selectedShape = null ;
280- recycleBin . classList . remove ( 'active' ) ;
281- }
282- } ) ;
28+ </ body >
28329
284- const initCamera = async ( ) => {
285- const stream = await navigator . mediaDevices . getUserMedia ( { video : { width : 1280 , height : 720 } } ) ;
286- video . srcObject = stream ;
287- await new Promise ( resolve => video . onloadedmetadata = resolve ) ;
288- canvas . width = video . videoWidth ;
289- canvas . height = video . videoHeight ;
290- new Camera ( video , {
291- onFrame : async ( ) => await hands . send ( { image : video } ) ,
292- width : video . videoWidth ,
293- height : video . videoHeight
294- } ) . start ( ) ;
295- } ;
30+ < script src ="main.js "> </ script >
29631
297- initThree ( ) ;
298- initCamera ( ) ;
299- </ script >
300- </ body >
30132</ html >
0 commit comments