[READ-ONLY] Mirror of https://github.com/flo-bit/room. tiny 3d rooms saved locally or in your bluesky account, svelte/threlte flo-bit.dev/room/
0

Configure Feed

Select the types of activity you want to include in your feed.

add placing objects on wall, add images

Florian (Mar 8, 2025, 11:23 PM +0100) fe87892b 6f96cba5

+1080 -182
+1 -1
README.md
··· 9 9 ## Credits 10 10 11 11 - made with svelte, threlte (three.js), tailwindcss 12 - - assets by [Kay Lousberg](https://kaylousberg.itch.io/furniture-bits) 12 + - assets by [Kay Lousberg](https://kaylousberg.itch.io/furniture-bits) and [Isa Lousberg](https://tinytreats.itch.io/bubbly-bathroom) 13 13 - color picker from [svelte-color-select](https://github.com/CaptainCodeman/svelte-color-select) 14 14 - ui from my own [ui-kit](https://github.com/flo-bit/ui-kit) which is based on [bits-ui](https://bits-ui.com/) 15 15 - bluesky login made possible by [atcute](https://github.com/mary-ext/atcute)
+44
src/lib/Image.svelte
··· 1 + <script lang="ts"> 2 + import type { Props } from '@threlte/core'; 3 + import { HTML } from '@threlte/extras'; 4 + import type * as THREE from 'three'; 5 + import { getImage } from './images.svelte'; 6 + import { userInfo } from './ui-state.svelte'; 7 + import { getImageBlobUrl } from './oauth/auth.svelte'; 8 + import { roomState } from './state.svelte'; 9 + 10 + let { 11 + ref = $bindable(), 12 + size = [270, 150], 13 + image, 14 + ...props 15 + }: Props<THREE.Group> & { 16 + ref?: THREE.Group; 17 + size?: [number, number]; 18 + image?: string; 19 + } = $props(); 20 + 21 + let myImage = $derived.by(() => { 22 + if (!image) return; 23 + 24 + if (typeof image === 'string' && image.startsWith('local:')) { 25 + return getImage(image.replace('local:', '')); 26 + // @ts-ignore 27 + } else if (typeof image === 'object' && image.$type === 'blob' && roomState.did) { 28 + // @ts-ignore 29 + return getImageBlobUrl({ did: roomState.did, link: image.ref.$link }); 30 + } 31 + return null; 32 + }); 33 + 34 + </script> 35 + 36 + <HTML transform bind:ref occlude="blending" scale={0.1} pointerEvents="none" {...props}> 37 + <div style="width: {size?.[0] ?? 270}px; height: {size?.[1] ?? 150}px;" class="bg-black"> 38 + {#await myImage then myImage} 39 + {#if myImage} 40 + <img src={myImage} alt="" class="h-full w-full object-cover select-none touch-none" /> 41 + {/if} 42 + {/await} 43 + </div> 44 + </HTML>
+89 -14
src/lib/Room.svelte
··· 4 4 import { 5 5 applyTransformOfSelected, 6 6 editorState, 7 + getPossiblePlacementForPlacingObject, 7 8 placeObject, 8 9 roomState, 9 10 type RoomObjectData ··· 19 20 </script> 20 21 21 22 <T.Group position.y={-1}> 22 - <T.Mesh position={[-0.05, 1 - 0.099, -roomState.size.x / 2 - 0.1]} receiveShadow> 23 + <T.Mesh 24 + onpointermove={(e: PointerEvent) => { 25 + if (getPossiblePlacementForPlacingObject() !== 'wall') return; 26 + 27 + e.stopPropagation(); 28 + 29 + if (editorState.placingObject) { 30 + editorState.placingObject.placement = 'wallZ'; 31 + editorState.placingObject.position = [e.point.x, e.point.y + 1, e.point.z]; 32 + } 33 + }} 34 + onclick={(e: PointerEvent) => { 35 + if (getPossiblePlacementForPlacingObject() !== 'wall') return; 36 + 37 + e.stopPropagation(); 38 + 39 + if (editorState.placingObject) { 40 + editorState.placingObject.placement = 'wallZ'; 41 + placeObject({ x: e.point.x, y: e.point.y + 1, z: e.point.z }); 42 + } 43 + }} 44 + position={[-0.05, 1 - 0.099, -roomState.size.x / 2 - 0.1]} 45 + receiveShadow 46 + castShadow 47 + ondblclick={() => { 48 + if (editorState.selectedObject) { 49 + applyTransformOfSelected(); 50 + 51 + editorState.selectedObject = null; 52 + } 53 + }} 54 + > 23 55 <RoundedBoxGeometry args={[roomState.size.z + 0.18, 2, 0.1]} radius={0.03} /> 24 56 <T.MeshStandardMaterial color={roomState.wallColor} /> 25 57 </T.Mesh> 26 58 27 - <T.Mesh position={[-roomState.size.z / 2 - 0.1, 1 - 0.099, -0.05]} receiveShadow> 59 + <T.Mesh 60 + onpointermove={(e: PointerEvent) => { 61 + if (getPossiblePlacementForPlacingObject() !== 'wall') return; 62 + 63 + e.stopPropagation(); 64 + 65 + if (editorState.placingObject) { 66 + editorState.placingObject.placement = 'wallX'; 67 + editorState.placingObject.position = [e.point.x, e.point.y + 1, e.point.z]; 68 + } 69 + }} 70 + onclick={(e: PointerEvent) => { 71 + if (getPossiblePlacementForPlacingObject() !== 'wall') return; 72 + 73 + e.stopPropagation(); 74 + 75 + if (editorState.placingObject) { 76 + editorState.placingObject.placement = 'wallX'; 77 + placeObject({ x: e.point.x, y: e.point.y + 1, z: e.point.z }); 78 + } 79 + }} 80 + position={[-roomState.size.z / 2 - 0.1, 1 - 0.099, -0.05]} 81 + receiveShadow 82 + castShadow 83 + ondblclick={() => { 84 + if (editorState.selectedObject) { 85 + applyTransformOfSelected(); 86 + 87 + editorState.selectedObject = null; 88 + } 89 + }} 90 + > 28 91 <RoundedBoxGeometry args={[0.1, 2, roomState.size.x + 0.18]} radius={0.03} /> 29 92 <T.MeshStandardMaterial color={roomState.wallColor} /> 30 93 </T.Mesh> ··· 40 103 <T.Mesh 41 104 position={[-0.05, -0.0502, -0.05]} 42 105 onpointermove={(e: PointerEvent) => { 106 + if (getPossiblePlacementForPlacingObject() !== 'floor') return; 107 + 43 108 e.stopPropagation(); 44 109 45 110 if (editorState.placingObject) { 46 - editorState.placingObject.position = [e.point.x, 0, e.point.z]; 111 + editorState.placingObject.position = [e.point.x, e.point.y + 1, e.point.z]; 47 112 } 48 113 }} 49 114 onclick={(e: PointerEvent) => { 115 + if (getPossiblePlacementForPlacingObject() !== 'floor') return; 116 + 50 117 e.stopPropagation(); 51 118 52 119 placeObject({ x: e.point.x, y: e.point.y + 1, z: e.point.z }); ··· 72 139 {#if editorState.selectedObject === object} 73 140 <TransformControls 74 141 bind:controls={editorState.transformControls} 75 - rotationSnap={Math.PI / 2} 76 142 position={object.position} 77 - rotation={[0, object.rotation, 0]} 143 + showX={object.placement !== 'wallX'} 144 + showZ={object.placement !== 'wallZ'} 145 + rotation={[ 146 + object.placement === 'wallX' ? object.rotation : 0, 147 + object.placement === 'floor' ? object.rotation : 0, 148 + object.placement === 'wallZ' ? object.rotation : 0 149 + ]} 78 150 enableRotate={false} 79 151 onchange={(e) => { 80 152 if (!e.target || !e.target.object) return; 81 153 82 154 e.target.object.position.clamp( 83 - new THREE.Vector3(-roomState.size.z / 2, 0, -roomState.size.x / 2), 84 - new THREE.Vector3(roomState.size.z / 2, 2, roomState.size.x / 2) 155 + new THREE.Vector3(-roomState.size.z / 2 - 0.1, 0, -roomState.size.x / 2 - 0.1), 156 + new THREE.Vector3(roomState.size.z / 2 + 0.1, 2, roomState.size.x / 2 + 0.1) 85 157 ); 86 158 }} 87 159 > 88 - <RoomObject kind={object.kind} colors={object.colors} /> 160 + <RoomObject kind={object.kind} colors={object.colors} placement={object.placement} image={object.image} /> 89 161 </TransformControls> 90 162 {:else if editorState.isEditing} 91 163 <T.Group 92 164 onclick={(evt: PointerEvent) => { 93 - evt.stopPropagation(); 94 165 if (editorState.placingObject) { 166 + if (getPossiblePlacementForPlacingObject() !== 'floor') return; 167 + 168 + evt.stopPropagation(); 95 169 placeObject({ x: evt.point.x, y: evt.point.y + 1, z: evt.point.z }); 96 170 return; 97 171 } 172 + evt.stopPropagation(); 98 173 99 174 applyTransformOfSelected(); 100 175 ··· 105 180 }, 0); 106 181 }} 107 182 onpointermove={(e: PointerEvent) => { 108 - e.stopPropagation(); 109 - 110 183 if (editorState.placingObject) { 184 + if (getPossiblePlacementForPlacingObject() !== 'floor') return; 185 + 111 186 editorState.placingObject.position = [e.point.x, e.point.y + 1, e.point.z]; 187 + e.stopPropagation(); 112 188 } 113 189 }} 114 190 > 115 - <RoomObject {...object} rotation={[0, object.rotation, 0]} /> 191 + <RoomObject {...object} /> 116 192 </T.Group> 117 193 {:else} 118 - <RoomObject {...object} rotation={[0, object.rotation, 0]} /> 194 + <RoomObject {...object} /> 119 195 {/if} 120 196 {/each} 121 197 122 198 {#if editorState.placingObject} 123 199 <RoomObject 124 200 {...editorState.placingObject} 125 - rotation={[0, editorState.placingObject.rotation, 0]} 126 201 colors={['#f1f1f1', '#f1f1f1', '#f1f1f1', '#f1f1f1', '#f1f1f1']} 127 202 opacity={0.8} 128 203 />
+10 -1
src/lib/RoomObject.svelte
··· 9 9 colors, 10 10 opacity, 11 11 scale, 12 + rotation, 13 + placement, 12 14 ...restProps 13 15 }: { 14 16 kind: RoomObjectKind; 15 17 selected?: boolean; 16 18 colors?: string[]; 17 19 opacity?: number; 20 + rotation?: number; 21 + placement?: 'floor' | 'wallX' | 'wallZ'; 18 22 } & Props<THREE.Group> = $props(); 19 23 20 24 let ObjectComponent = AllObjects[kind]?.component; ··· 27 31 {...restProps} 28 32 {colors} 29 33 {opacity} 30 - videoId={'https://www.youtube.com/watch?v=k1BneeJTDcU'} 31 34 scale={scale ?? AllObjects[kind]?.scale ?? 1} 35 + rotation={[ 36 + placement === 'wallX' ? (rotation ?? 0) : 0, 37 + (placement === 'floor' ? (rotation ?? 0) : 0) + (placement === 'wallX' ? Math.PI / 2 : 0), 38 + placement === 'wallZ' ? (rotation ?? 0) : 0 39 + ]} 40 + {placement} 32 41 /> 33 42 {/snippet}
+1 -1
src/lib/Youtube.svelte
··· 21 21 </script> 22 22 23 23 <HTML transform bind:ref occlude="blending" scale={0.1} pointerEvents="none" {...props}> 24 - <div style="width: {size?.[0] ?? 270}px; height: {size?.[1] ?? 150}px;"> 24 + <div style="width: {size?.[0] ?? 270}px; height: {size?.[1] ?? 150}px;" class="select-none touch-none"> 25 25 <VideoPlayer id={videoId} bind:playPause /> 26 26 </div> 27 27 </HTML>
+15
src/lib/analytics.ts
··· 1 + import { client } from './oauth'; 2 + 3 + export async function trackEvent(event: string, data?: Record<string, string | number>) { 4 + if (client.profile) { 5 + data = { 6 + handle: client.profile.handle, 7 + ...data 8 + }; 9 + } 10 + // @ts-expect-error umami is not defined in the global scope 11 + if (window.umami) { 12 + // @ts-expect-error umami is not defined in the global scope 13 + window.umami?.track(event, data); 14 + } 15 + }
+2 -2
src/lib/components/base/sonner/Toaster.svelte
··· 57 57 unstyled: true, 58 58 classes: { 59 59 toast: 60 - 'group toast min-w-12 w-fit sm:min-w-64 backdrop-blur-lg border rounded-2xl p-4 flex items-center gap-2 sm:fixed sm:top-4 sm:right-0 mx-2', 60 + 'group toast min-w-12 w-fit sm:min-w-64 backdrop-blur-lg border rounded-2xl p-4 flex items-center gap-2 sm:fixed sm:bottom-4 sm:right-0 mx-2', 61 61 title: 'text-base title', 62 62 description: 'text-xs mt-1 description', 63 63 ··· 69 69 info: colorClasses[colors?.info ?? 'blue'] 70 70 } 71 71 }} 72 - position="top-right" 72 + position="bottom-right" 73 73 />
+129
src/lib/images.svelte.ts
··· 1 + const imageState: { 2 + db: IDBDatabase | null; 3 + } = $state({ 4 + db: null 5 + }); 6 + 7 + export function openDB(): Promise<IDBDatabase> { 8 + if (imageState.db) { 9 + return Promise.resolve(imageState.db); 10 + } 11 + 12 + return new Promise((resolve, reject) => { 13 + const request = indexedDB.open('ImageDB', 1); 14 + 15 + request.onupgradeneeded = function (event) { 16 + if (!event.target || !('result' in event.target)) { 17 + console.error('Error opening IndexedDB.'); 18 + reject(new Error('Error opening IndexedDB.')); 19 + return; 20 + } 21 + 22 + const db = event.target.result as IDBDatabase; 23 + // Create an object store to hold image blobs if it doesn't already exist 24 + if (!db.objectStoreNames.contains('images')) { 25 + db.createObjectStore('images', { keyPath: 'id' }); 26 + } 27 + }; 28 + 29 + request.onsuccess = function (event) { 30 + if (!event.target || !('result' in event.target)) { 31 + console.error('Error opening IndexedDB.'); 32 + reject(new Error('Error opening IndexedDB.')); 33 + return; 34 + } 35 + 36 + imageState.db = event.target.result as IDBDatabase; 37 + resolve(imageState.db); 38 + }; 39 + 40 + request.onerror = function () { 41 + console.error('Error opening IndexedDB.'); 42 + reject(new Error('Error opening IndexedDB.')); 43 + }; 44 + }); 45 + } 46 + 47 + export function saveImage(imageId: string, image: Blob) { 48 + if (!imageState.db) { 49 + console.error('DB not open'); 50 + return; 51 + } 52 + const transaction = imageState.db.transaction(['images'], 'readwrite'); 53 + const store = transaction.objectStore('images'); 54 + store.put({ id: imageId, blob: image }); 55 + } 56 + 57 + export async function getImage(imageId: string): Promise<string> { 58 + if (!imageState.db) { 59 + await openDB(); 60 + } 61 + const db = imageState.db; 62 + return new Promise((resolve, reject) => { 63 + if (!db) return reject(new Error('DB not open')); 64 + 65 + // Ensure the 'images' object store exists 66 + if (!db.objectStoreNames.contains('images')) { 67 + reject(new Error("Object store 'images' not found in the provided database.")); 68 + return; 69 + } 70 + 71 + const transaction = db.transaction(['images'], 'readonly'); 72 + const store = transaction.objectStore('images'); 73 + const getRequest = store.get(imageId); 74 + 75 + getRequest.onsuccess = function (event) { 76 + if (!event.target || !('result' in event.target)) { 77 + reject(new Error('Error retrieving the image from IndexedDB')); 78 + return; 79 + } 80 + const record = event.target.result; 81 + if (record && typeof record === 'object' && 'blob' in record) { 82 + const objectURL = URL.createObjectURL(record.blob as Blob); 83 + resolve(objectURL); 84 + } else { 85 + reject(new Error('Image not found in IndexedDB')); 86 + } 87 + }; 88 + 89 + getRequest.onerror = function () { 90 + reject(new Error('Error retrieving the image from IndexedDB')); 91 + }; 92 + }); 93 + } 94 + 95 + export function deleteImage(imageId: string) { 96 + if (!imageState.db) { 97 + console.error('DB not open'); 98 + return; 99 + } 100 + const transaction = imageState.db.transaction(['images'], 'readwrite'); 101 + const store = transaction.objectStore('images'); 102 + store.delete(imageId); 103 + console.log('Deleted image', imageId); 104 + } 105 + 106 + export function getAllImageNames(): Promise<string[]> { 107 + return new Promise((resolve, reject) => { 108 + if (!imageState.db) { 109 + reject(new Error('DB not open')); 110 + return; 111 + } 112 + const db = imageState.db; 113 + if (!db.objectStoreNames.contains('images')) { 114 + reject(new Error("Object store 'images' not found in the provided database.")); 115 + return; 116 + } 117 + const transaction = db.transaction(['images'], 'readonly'); 118 + const store = transaction.objectStore('images'); 119 + const request = store.getAllKeys(); 120 + 121 + request.onsuccess = function () { 122 + resolve(request.result as string[]); 123 + }; 124 + 125 + request.onerror = function () { 126 + reject(new Error('Error retrieving image keys from IndexedDB')); 127 + }; 128 + }); 129 + }
+15 -5
src/lib/models.ts
··· 94 94 import KayKitTableMedium from './models/kaykit-furniture-kit/table_medium.svelte'; 95 95 import KayKitShelfBSmall from './models/kaykit-furniture-kit/shelf_b_small.svelte'; 96 96 import KayKitCabinetMedium from './models/kaykit-furniture-kit/cabinet_medium.svelte'; 97 + import Plant from './models/tiny-treats-bubbly-bathroom/plant.svelte'; 97 98 98 99 export const AllObjects: { 99 100 [key: string]: { ··· 333 334 component: KayKitBookSet, 334 335 colors: 4, 335 336 visible: true, 336 - placement: 'wall', 337 + placement: 'floor', 337 338 image: false, 338 339 scale: 0.4, 339 340 category: 'miscellaneous' ··· 413 414 'kaykit-furniture-kit-pictureframe_standing_b': { 414 415 component: KayKitPictureframeStandingB, 415 416 colors: 1, 416 - visible: false, 417 + visible: true, 417 418 placement: 'floor', 418 419 image: true, 419 420 scale: 0.4, ··· 566 567 'kaykit-furniture-kit-pictureframe_standing_a': { 567 568 component: KayKitPictureframeStandingA, 568 569 colors: 1, 569 - visible: false, 570 + visible: true, 570 571 placement: 'floor', 571 572 image: true, 572 573 scale: 0.4, ··· 639 640 component: KayKitBookSingle, 640 641 colors: 2, 641 642 visible: true, 642 - placement: 'wall', 643 + placement: 'floor', 643 644 image: false, 644 645 scale: 0.4, 645 646 category: 'miscellaneous' ··· 850 851 image: false, 851 852 scale: 0.4, 852 853 category: 'tables & cabinets' 854 + }, 855 + 'tiny-treats-bubbly-bathroom-plant': { 856 + component: Plant, 857 + colors: 2, 858 + visible: true, 859 + placement: 'floor', 860 + image: false, 861 + scale: 0.4, 862 + category: 'miscellaneous' 853 863 } 854 864 }; 855 865 856 866 export const visibleObjects = Object.fromEntries( 857 867 Object.entries(AllObjects) 858 - .filter(([, value]) => value.visible !== false && value.placement === 'floor') 868 + .filter(([, value]) => value.visible !== false) 859 869 .sort((a, b) => a[0].localeCompare(b[0])) 860 870 ); 861 871 export const visibleKeys = Object.keys(visibleObjects);
-5
src/lib/models/RoomObjectMaterial.svelte
··· 8 8 colors, 9 9 opacity, 10 10 youtube, 11 - image, 12 11 isImage, 13 12 ...props 14 13 }: { ··· 16 15 colors?: string[]; 17 16 opacity?: number; 18 17 youtube?: string; 19 - image?: string; 20 18 } & Props<MeshStandardMaterial> = $props(); 21 19 22 20 function getColor() { 23 - if (image) { 24 - return defaultColorImage; 25 - } 26 21 return colors?.[index ?? 0] ?? defaultColors[index ?? 0]; 27 22 } 28 23 </script>
+5 -6
src/lib/models/kaykit-furniture-kit/pictureframe_large_a.svelte
··· 13 13 import { type ExtraRoomObjectProps } from '../types'; 14 14 import RoomObjectMaterial from '../RoomObjectMaterial.svelte'; 15 15 import { base } from '$app/paths'; 16 + import Image from '$lib/Image.svelte'; 16 17 17 18 let { 18 19 fallback, ··· 34 35 35 36 type GLTFResult = { 36 37 nodes: { 37 - Cube018: THREE.Mesh; 38 - Cube018_1: THREE.Mesh; 38 + Cube016: THREE.Mesh; 39 + Cube016_1: THREE.Mesh; 39 40 }; 40 41 materials: { 41 42 a: THREE.MeshStandardMaterial; ··· 53 54 {@render fallback?.()} 54 55 {:then gltf} 55 56 <T.Group> 56 - <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube018.geometry} 57 + <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube016.geometry} 57 58 ><RoomObjectMaterial index={0} {colors} {opacity} /></T.Mesh 58 59 > 59 - <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube018_1.geometry} 60 - ><RoomObjectMaterial {image} {colors} {opacity} /></T.Mesh 61 - > 60 + <Image image={image} position={[0, 0, 0.15]} size={[270 * 1.3, 150 * 2.6]} /> 62 61 </T.Group> 63 62 {:catch err} 64 63 {@render error?.({ error: err })}
+6 -6
src/lib/models/kaykit-furniture-kit/pictureframe_large_b.svelte
··· 13 13 import { type ExtraRoomObjectProps } from '../types'; 14 14 import RoomObjectMaterial from '../RoomObjectMaterial.svelte'; 15 15 import { base } from '$app/paths'; 16 + import Image from '$lib/Image.svelte'; 16 17 17 18 let { 18 19 fallback, ··· 34 35 35 36 type GLTFResult = { 36 37 nodes: { 37 - Cube016: THREE.Mesh; 38 - Cube016_1: THREE.Mesh; 38 + Cube018: THREE.Mesh; 39 + Cube018_1: THREE.Mesh; 39 40 }; 40 41 materials: { 41 42 a: THREE.MeshStandardMaterial; ··· 53 54 {@render fallback?.()} 54 55 {:then gltf} 55 56 <T.Group> 56 - <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube016.geometry} 57 + <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube018.geometry} 57 58 ><RoomObjectMaterial index={0} {colors} {opacity} /></T.Mesh 58 59 > 59 - <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube016_1.geometry} 60 - ><RoomObjectMaterial {image} {colors} {opacity} /></T.Mesh 61 - > 60 + 61 + <Image image={image} position={[0, 0, 0.15]} size={[270 * 2.65, 150 * 2.6]} /> 62 62 </T.Group> 63 63 {:catch err} 64 64 {@render error?.({ error: err })}
+3 -3
src/lib/models/kaykit-furniture-kit/pictureframe_medium.svelte
··· 13 13 import { type ExtraRoomObjectProps } from '../types'; 14 14 import RoomObjectMaterial from '../RoomObjectMaterial.svelte'; 15 15 import { base } from '$app/paths'; 16 + import Image from '$lib/Image.svelte'; 16 17 17 18 let { 18 19 fallback, ··· 56 57 <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube015.geometry} 57 58 ><RoomObjectMaterial index={0} {colors} {opacity} /></T.Mesh 58 59 > 59 - <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube015_1.geometry} 60 - ><RoomObjectMaterial {image} {colors} {opacity} /></T.Mesh 61 - > 60 + <Image image={image} position={[0, 0, 0.15]} size={[270 * 0.7, 150 * 1.8]} /> 61 + 62 62 </T.Group> 63 63 {:catch err} 64 64 {@render error?.({ error: err })}
+3 -3
src/lib/models/kaykit-furniture-kit/pictureframe_small_a.svelte
··· 13 13 import { type ExtraRoomObjectProps } from '../types'; 14 14 import RoomObjectMaterial from '../RoomObjectMaterial.svelte'; 15 15 import { base } from '$app/paths'; 16 + import Image from '$lib/Image.svelte'; 16 17 17 18 let { 18 19 fallback, ··· 56 57 <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube014.geometry} 57 58 ><RoomObjectMaterial index={0} {colors} {opacity} /></T.Mesh 58 59 > 59 - <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube014_1.geometry} 60 - ><RoomObjectMaterial {image} {colors} {opacity} /></T.Mesh 61 - > 60 + <Image image={image} position={[0, 0.0, 0.15]} size={[270 * 0.5, 150 * 1.1]} /> 61 + 62 62 </T.Group> 63 63 {:catch err} 64 64 {@render error?.({ error: err })}
+6 -6
src/lib/models/kaykit-furniture-kit/pictureframe_small_b.svelte
··· 13 13 import { type ExtraRoomObjectProps } from '../types'; 14 14 import RoomObjectMaterial from '../RoomObjectMaterial.svelte'; 15 15 import { base } from '$app/paths'; 16 + import Image from '$lib/Image.svelte'; 16 17 17 18 let { 18 19 fallback, ··· 34 35 35 36 type GLTFResult = { 36 37 nodes: { 37 - Cube025: THREE.Mesh; 38 - Cube025_1: THREE.Mesh; 38 + Cube017: THREE.Mesh; 39 + Cube017_1: THREE.Mesh; 39 40 }; 40 41 materials: { 41 42 a: THREE.MeshStandardMaterial; ··· 53 54 {@render fallback?.()} 54 55 {:then gltf} 55 56 <T.Group> 56 - <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube025.geometry} 57 + <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube017.geometry} 57 58 ><RoomObjectMaterial index={0} {colors} {opacity} /></T.Mesh 58 59 > 59 - <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube025_1.geometry} 60 - ><RoomObjectMaterial {image} {colors} {opacity} /></T.Mesh 61 - > 60 + <Image image={image} position={[0, 0.0, 0.15]} size={[270 * 0.7, 150 * 0.6]} /> 61 + 62 62 </T.Group> 63 63 {:catch err} 64 64 {@render error?.({ error: err })}
+5 -6
src/lib/models/kaykit-furniture-kit/pictureframe_small_c.svelte
··· 13 13 import { type ExtraRoomObjectProps } from '../types'; 14 14 import RoomObjectMaterial from '../RoomObjectMaterial.svelte'; 15 15 import { base } from '$app/paths'; 16 + import Image from '$lib/Image.svelte'; 16 17 17 18 let { 18 19 fallback, ··· 34 35 35 36 type GLTFResult = { 36 37 nodes: { 37 - Cube017: THREE.Mesh; 38 - Cube017_1: THREE.Mesh; 38 + Cube025: THREE.Mesh; 39 + Cube025_1: THREE.Mesh; 39 40 }; 40 41 materials: { 41 42 a: THREE.MeshStandardMaterial; ··· 53 54 {@render fallback?.()} 54 55 {:then gltf} 55 56 <T.Group> 56 - <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube017.geometry} 57 + <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube025.geometry} 57 58 ><RoomObjectMaterial index={0} {colors} {opacity} /></T.Mesh 58 59 > 59 - <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube017_1.geometry} 60 - ><RoomObjectMaterial {image} {colors} {opacity} /></T.Mesh 61 - > 60 + <Image image={image} position={[0, 0.0, 0.15]} size={[270 * 0.5, 270 * 0.5]} /> 62 61 </T.Group> 63 62 {:catch err} 64 63 {@render error?.({ error: err })}
+2 -3
src/lib/models/kaykit-furniture-kit/pictureframe_standing_a.svelte
··· 13 13 import { type ExtraRoomObjectProps } from '../types'; 14 14 import RoomObjectMaterial from '../RoomObjectMaterial.svelte'; 15 15 import { base } from '$app/paths'; 16 + import Image from '$lib/Image.svelte'; 16 17 17 18 let { 18 19 fallback, ··· 56 57 <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube024.geometry} 57 58 ><RoomObjectMaterial index={0} {colors} {opacity} /></T.Mesh 58 59 > 59 - <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube024_1.geometry} 60 - ><RoomObjectMaterial {image} {colors} {opacity} /></T.Mesh 61 - > 60 + <Image image={image} position={[0, 0.33, 0.05]} size={[270 * 0.5, 150 * 1.1]} rotation.x={-0.35} /> 62 61 </T.Group> 63 62 {:catch err} 64 63 {@render error?.({ error: err })}
+3 -3
src/lib/models/kaykit-furniture-kit/pictureframe_standing_b.svelte
··· 13 13 import { type ExtraRoomObjectProps } from '../types'; 14 14 import RoomObjectMaterial from '../RoomObjectMaterial.svelte'; 15 15 import { base } from '$app/paths'; 16 + import Image from '$lib/Image.svelte'; 16 17 17 18 let { 18 19 fallback, ··· 55 56 <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube023.geometry} 56 57 ><RoomObjectMaterial index={0} {colors} {opacity} /></T.Mesh 57 58 > 58 - <T.Mesh castShadow receiveShadow geometry={gltf.nodes.Cube023_1.geometry} 59 - ><RoomObjectMaterial {image} {colors} {opacity} /></T.Mesh 60 - > 59 + 60 + <Image image={image} position={[0, 0.25, 0.05]} size={[270 * 0.7, 150 * 0.6]} rotation.x={-0.35} /> 61 61 {:catch err} 62 62 {@render error?.({ error: err })} 63 63 {/await}
+62
src/lib/models/tiny-treats-bubbly-bathroom/plant.svelte
··· 1 + <!-- 2 + Auto-generated by: https://github.com/threlte/threlte/tree/main/packages/gltf 3 + Command: npx @threlte/gltf@3.0.0 -t -s -u models/tiny-treats-bubbly-bathroom/plant.glb 4 + --> 5 + 6 + <script lang="ts"> 7 + import type * as THREE from 'three'; 8 + 9 + import type { Snippet } from 'svelte'; 10 + import { T, type Props } from '@threlte/core'; 11 + import { useGltf, useSuspense } from '@threlte/extras'; 12 + import { base } from '$app/paths'; 13 + import type { ExtraRoomObjectProps } from '../types'; 14 + import RoomObjectMaterial from '../RoomObjectMaterial.svelte'; 15 + 16 + let { 17 + fallback, 18 + error, 19 + children, 20 + ref = $bindable(), 21 + colors, 22 + opacity, 23 + ...props 24 + }: Props<THREE.Group> & { 25 + ref?: THREE.Group; 26 + children?: Snippet<[{ ref: THREE.Group }]>; 27 + fallback?: Snippet; 28 + error?: Snippet<[{ error: Error }]>; 29 + } & ExtraRoomObjectProps = $props(); 30 + 31 + const suspend = useSuspense(); 32 + 33 + type GLTFResult = { 34 + nodes: { 35 + plant_1: THREE.Mesh; 36 + plant_2: THREE.Mesh; 37 + }; 38 + materials: { 39 + a: THREE.MeshStandardMaterial; 40 + b: THREE.MeshStandardMaterial; 41 + }; 42 + }; 43 + 44 + const gltf = suspend(useGltf<GLTFResult>(base + '/models/tiny-treats-bubbly-bathroom/plant.glb')); 45 + </script> 46 + 47 + <T.Group bind:ref dispose={false} {...props}> 48 + {#await gltf} 49 + {@render fallback?.()} 50 + {:then gltf} 51 + <T.Mesh castShadow receiveShadow geometry={gltf.nodes.plant_1.geometry}> 52 + <RoomObjectMaterial {colors} {opacity} index={0} /> 53 + </T.Mesh> 54 + <T.Mesh castShadow receiveShadow geometry={gltf.nodes.plant_2.geometry}> 55 + <RoomObjectMaterial {colors} {opacity} index={1} /> 56 + </T.Mesh> 57 + {:catch err} 58 + {@render error?.({ error: err })} 59 + {/await} 60 + 61 + {@render children?.({ ref })} 62 + </T.Group>
+102 -11
src/lib/oauth/auth.svelte.ts
··· 12 12 import { metadata } from './const'; 13 13 import { AtpBaseClient } from '@atproto/api'; 14 14 import type { DidDocument } from '@atcute/client/utils/did'; 15 + import { 16 + applyTransformOfSelected, 17 + editorState, 18 + roomState, 19 + type BlueskyBlob 20 + } from '$lib/state.svelte'; 21 + import { toast } from 'svelte-sonner'; 22 + import { modals } from '$lib/ui-state.svelte'; 23 + import { getImage } from '$lib/images.svelte'; 24 + import { trackEvent } from '$lib/analytics'; 15 25 16 26 export const client = $state({ 17 27 agent: null as OAuthUserAgent | null, ··· 211 221 }; 212 222 213 223 export async function getRoom({ did }: { did: string }) { 214 - if (!agent) { 215 - agent = new AtpBaseClient({ service: 'https://api.bsky.app' }); 216 - } 217 - 218 - // // get profile 219 - // const { data } = await agent.com.atproto.sync.getRecord({ 220 - // did: did, 221 - // collection: 'app.bsky.actor.profile', 222 - // rkey: 'self' 223 - // }); 224 - 225 224 const pds = await getPDS(did); 226 225 const rpc = new XRPC({ handler: new CredentialManager({ service: pds }) }); 227 226 ··· 235 234 236 235 // get profile 237 236 const profile = await getProfile({ did }); 237 + // @ts-expect-error data.value is not typed 238 + data.value.roomState.did = did; 238 239 240 + // @ts-expect-error data.value is not typed 239 241 return { room: data.value.roomState, profile: profile }; 240 242 } 241 243 ··· 256 258 const data = await agent.com.atproto.identity.resolveHandle({ handle }); 257 259 return data.data.did; 258 260 } 261 + 262 + export async function uploadImage(image: Blob) { 263 + if (!client.profile) throw new Error('No profile'); 264 + 265 + console.log('uploading image', image); 266 + 267 + // atcute version 268 + const blobResponse = await client.rpc?.request({ 269 + type: 'post', 270 + nsid: 'com.atproto.repo.uploadBlob', 271 + params: { 272 + repo: client.profile.did 273 + }, 274 + data: image 275 + }); 276 + 277 + return blobResponse?.data.blob as { 278 + $type: 'blob'; 279 + ref: { 280 + $link: string; 281 + }; 282 + mimeType: string; 283 + size: number; 284 + }; 285 + } 286 + 287 + export async function getImageBlobUrl({ did, link }: { did: string; link: string }) { 288 + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${link}@jpeg`; 289 + } 290 + 291 + export async function saveRoomToBluesky() { 292 + if (!client.rpc || !client.profile) { 293 + throw new Error('Failed to save room, please log in first'); 294 + } 295 + 296 + roomState.did = client.profile.did; 297 + 298 + applyTransformOfSelected(); 299 + 300 + editorState.selectedObject = null; 301 + 302 + try { 303 + trackEvent('room_save_try'); 304 + 305 + const uploadedMap: Record<string, BlueskyBlob> = {}; 306 + // upload all images 307 + for (const object of roomState.objects) { 308 + if (typeof object.image !== 'string' || !object.image.startsWith('local:')) continue; 309 + 310 + if (uploadedMap[object.image]) { 311 + object.image = uploadedMap[object.image]; 312 + continue; 313 + } 314 + const name = object.image; 315 + const imageDataUrl = await getImage(name.replace('local:', '')); 316 + const blob = await fetch(imageDataUrl).then((res) => res.blob()); 317 + object.image = await uploadImage(blob); 318 + uploadedMap[name] = object.image; 319 + } 320 + 321 + await client.rpc.call('com.atproto.repo.putRecord', { 322 + data: { 323 + collection: 'dev.flo-bit.room', 324 + repo: client.profile.did, 325 + rkey: 'self', 326 + record: { 327 + roomState: JSON.parse( 328 + JSON.stringify({ 329 + wallColor: roomState.wallColor, 330 + floorColor: roomState.floorColor, 331 + objects: roomState.objects, 332 + size: roomState.size, 333 + id: roomState.id, 334 + version: roomState.version 335 + }) 336 + ) 337 + } 338 + } 339 + }); 340 + 341 + trackEvent('room_save_success'); 342 + 343 + modals.successModalState = true; 344 + } catch (e) { 345 + console.error(e); 346 + trackEvent('room_save_error'); 347 + throw new Error('Failed to save room, please try again'); 348 + } 349 + }
+40 -5
src/lib/state.svelte.ts
··· 1 1 import type { TransformControls } from '@threlte/extras'; 2 - import type { RoomObjectKind } from './models'; 2 + import { AllObjects, type RoomObjectKind } from './models'; 3 + import { trackEvent } from './analytics'; 3 4 4 5 export const currentVersion = 0; 5 6 ··· 13 14 id: number; 14 15 15 16 version: number; 17 + 18 + did: string; 16 19 } = $state({ 17 20 wallColor: '#f1f1f1', 18 21 floorColor: '#a1a1a1', ··· 23 26 24 27 id: 0, 25 28 26 - version: currentVersion 29 + version: currentVersion, 30 + 31 + did: '' 27 32 }); 28 33 29 34 export const editorState: { ··· 46 51 isEditing: false 47 52 }); 48 53 54 + export type BlueskyBlob = { 55 + $type: 'blob'; 56 + ref: { 57 + $link: string; 58 + }; 59 + mimeType: string; 60 + size: number; 61 + }; 62 + 49 63 export type RoomObjectData = { 50 64 kind: RoomObjectKind; 51 65 position: [number, number, number]; ··· 53 67 colors: string[]; 54 68 placement: 'floor' | 'wallX' | 'wallZ'; 55 69 link?: string; 70 + image?: string | BlueskyBlob; 56 71 }; 57 72 58 73 export function applyTransformOfSelected() { ··· 63 78 editorState.transformControls.object.position.z 64 79 ]; 65 80 66 - editorState.selectedObject.rotation = editorState.transformControls.object.rotation.y; 81 + if (editorState.selectedObject.placement === 'wallX') { 82 + editorState.selectedObject.rotation = editorState.transformControls.object.rotation.x; 83 + } else if (editorState.selectedObject.placement === 'wallZ') { 84 + editorState.selectedObject.rotation = editorState.transformControls.object.rotation.z; 85 + } else { 86 + editorState.selectedObject.rotation = editorState.transformControls.object.rotation.y; 87 + } 67 88 68 89 saveRoomToLocalStorage(); 69 90 } 70 91 } 71 92 72 93 export function saveRoomToLocalStorage() { 94 + trackEvent('room_save_local', { count: roomState.objects.length }); 95 + 73 96 localStorage.setItem('roomState', JSON.stringify(roomState)); 74 97 } 75 98 ··· 93 116 } 94 117 95 118 export function rotateRight() { 96 - rotateObject(Math.PI / 6); 119 + rotateObject(Math.PI / 8); 97 120 } 98 121 99 122 export function rotateLeft() { 100 - rotateObject(-Math.PI / 6); 123 + rotateObject(-Math.PI / 8); 101 124 } 102 125 103 126 export function tryLoadingRoomFromLocalStorage(otherWiseReset: boolean = false) { 104 127 const room = localStorage.getItem('roomState'); 105 128 if (room) { 129 + trackEvent('room_load_local', { count: roomState.objects.length }); 130 + 106 131 const roomObject = JSON.parse(room); 107 132 roomState.floorColor = roomObject.floorColor; 108 133 roomState.wallColor = roomObject.wallColor; 109 134 roomState.objects = roomObject.objects; 110 135 roomState.size = roomObject.size; 111 136 roomState.id = roomObject.id ?? Math.random(); 137 + roomState.did = roomObject.did; 112 138 } else if (otherWiseReset) { 113 139 roomState.floorColor = '#a1a1a1'; 114 140 roomState.wallColor = '#f1f1f1'; ··· 116 142 roomState.size = { x: 2, z: 3 }; 117 143 roomState.id = Math.random(); 118 144 roomState.version = currentVersion; 145 + roomState.did = ''; 119 146 } 120 147 } 121 148 ··· 145 172 roomState.objects = roomState.objects.filter((object) => object !== editorState.selectedObject); 146 173 } 147 174 } 175 + 176 + export function getPossiblePlacementForPlacingObject() { 177 + if (!editorState.placingObject) return undefined; 178 + 179 + const object = editorState.placingObject; 180 + 181 + return AllObjects[object.kind]?.placement ?? 'floor'; 182 + }
+9 -85
src/lib/ui-state.svelte.ts
··· 1 - import { toast } from 'svelte-sonner'; 1 + import { trackEvent } from './analytics'; 2 2 import { AllObjects } from './models'; 3 - import { client, getRoom, resolveHandle } from './oauth/auth.svelte'; 4 - import { 5 - applyTransformOfSelected, 6 - editorState, 7 - roomState, 8 - type RoomObjectData 9 - } from './state.svelte'; 3 + import { getRoom, resolveHandle } from './oauth/auth.svelte'; 4 + import { roomState, type RoomObjectData } from './state.svelte'; 10 5 import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; 11 6 12 7 export const modals = $state({ ··· 30 25 31 26 export async function loadRoomFromBluesky(handle: string) { 32 27 const did = await resolveHandle({ handle }); 33 - console.log(did); 34 28 const room = await getRoom({ did }); 35 - console.log(room); 36 29 30 + roomState.did = room.room.did || did; 37 31 roomState.wallColor = room.room.wallColor; 38 32 roomState.floorColor = room.room.floorColor; 39 33 ··· 50 44 roomState.id = room.room.id; 51 45 roomState.version = room.room.version; 52 46 53 - // @ts-expect-error umami is not defined in the global scope 54 - if (window.umami) { 55 - // @ts-expect-error umami is not defined in the global scope 56 - window.umami.track('room_view', { 57 - handle: handle 58 - }); 59 - } 60 - return room.profile; 61 - } 62 - 63 - export async function saveRoomToBluesky() { 64 - if (!client.rpc || !client.profile) { 65 - toast.error('Failed to save room, please log in first'); 66 - return; 67 - } 68 - 69 - applyTransformOfSelected(); 70 - 71 - editorState.selectedObject = null; 72 - 73 - try { 74 - // @ts-expect-error umami is not defined in the global scope 75 - if (window.umami) { 76 - // @ts-expect-error umami is not defined in the global scope 77 - window.umami?.track('room_save_try', { 78 - handle: client.profile.handle, 79 - did: client.profile.did, 80 - objectCount: roomState.objects.length 81 - }); 82 - } 83 - 84 - await client.rpc.call('com.atproto.repo.putRecord', { 85 - data: { 86 - collection: 'dev.flo-bit.room', 87 - repo: client.profile.did, 88 - rkey: 'self', 89 - record: { 90 - roomState: JSON.parse( 91 - JSON.stringify({ 92 - wallColor: roomState.wallColor, 93 - floorColor: roomState.floorColor, 94 - objects: roomState.objects, 95 - size: roomState.size, 96 - id: roomState.id, 97 - version: roomState.version 98 - }) 99 - ) 100 - } 101 - } 102 - }); 47 + userInfo.profile = room.profile; 103 48 104 - // @ts-expect-error umami is not defined in the global scope 105 - if (window.umami) { 106 - // @ts-expect-error umami is not defined in the global scope 107 - window.umami?.track('room_save_success', { 108 - handle: client.profile.handle, 109 - did: client.profile.did, 110 - objectCount: roomState.objects.length 111 - }); 112 - console.log('saved room_save'); 113 - } 114 - 115 - modals.successModalState = true; 49 + trackEvent('room_view', { 50 + view_handle: handle 51 + }); 116 52 117 - toast.success('Room saved to your profile'); 118 - } catch (e) { 119 - console.error(e); 120 - // @ts-expect-error umami is not defined in the global scope 121 - if (window.umami) { 122 - // @ts-expect-error umami is not defined in the global scope 123 - window.umami?.track('room_save_error', { 124 - handle: client.profile.handle, 125 - did: client.profile.did 126 - }); 127 - } 128 - toast.error('Failed to save room, please try again'); 129 - } 53 + return room.profile; 130 54 }
+2
src/routes/+page.svelte
··· 23 23 24 24 let loading = $state(true); 25 25 26 + let db = $state<IDBDatabase | null>(null); 27 + 26 28 onMount(async () => { 27 29 window.addEventListener('keydown', (e) => { 28 30 if (e.key === 'x') {
+12 -2
src/routes/EditorUI.svelte
··· 27 27 import { Heading, Subheading } from '$lib/components/base/heading'; 28 28 import NumberInput from '$lib/components/base/number-input/NumberInput.svelte'; 29 29 import { Input } from '$lib/components/base/input'; 30 - import { loadRoomFromBluesky, modals, saveRoomToBluesky, userInfo } from '$lib/ui-state.svelte'; 30 + import { loadRoomFromBluesky, modals, userInfo } from '$lib/ui-state.svelte'; 31 31 import { blueskyLoginModalState } from '$lib/components/base/modal/BlueskyLoginModal.svelte'; 32 32 import { toast } from 'svelte-sonner'; 33 33 import Picker from './Picker.svelte'; 34 + import ImageSelector from './ImageSelector.svelte'; 35 + import { saveRoomToBluesky } from '$lib/oauth/auth.svelte'; 34 36 35 37 let lastUsedColors = $derived.by(() => { 36 38 let colors = []; ··· 297 299 </svg> 298 300 </Button> 299 301 {/if} 302 + 303 + {#if AllObjects[editorState.selectedObject.kind].image} 304 + <ImageSelector /> 305 + {/if} 300 306 {/if} 301 307 </div> 302 308 <div class="fixed top-4 right-4 -z-10 flex flex-col items-end gap-2"> ··· 323 329 {:else} 324 330 <Button 325 331 onclick={() => { 326 - saveRoomToBluesky(); 332 + toast.promise(saveRoomToBluesky(), { 333 + loading: 'Saving...', 334 + success: 'Room saved to your profile', 335 + error: 'Failed to save room, please try again' 336 + }); 327 337 }}>Save & Share</Button 328 338 > 329 339 {/if}
+268
src/routes/ImageSelector.svelte
··· 1 + <script lang="ts"> 2 + import { deleteImage, getAllImageNames, getImage, openDB, saveImage } from '$lib/images.svelte'; 3 + import { onMount } from 'svelte'; 4 + import * as Popover from '$lib/components/base/popover'; 5 + import { cn } from '$lib/utils'; 6 + import { editorState } from '$lib/state.svelte'; 7 + import { buttonVariants } from '$lib/components/base/button'; 8 + import { uploadImage } from '$lib/oauth/auth.svelte'; 9 + 10 + // Open the IndexedDB on component mount 11 + onMount(async () => { 12 + await openDB(); 13 + 14 + loadImages(); 15 + }); 16 + 17 + // Save the selected image 18 + async function handleFileChange(event: Event) { 19 + const target = event.target as HTMLInputElement; 20 + if (target.files) { 21 + for (const file of target.files) { 22 + const compressedFile = await compressImage(file); 23 + saveImage(new Date().getTime().toString() + crypto.randomUUID(), compressedFile); 24 + } 25 + await loadImages(); 26 + 27 + // select the first image 28 + if (images.length > 0) { 29 + handleSelectImage(images[0]); 30 + } 31 + } 32 + } 33 + 34 + export function compressImage(file: File, maxSize: number = 100 * 1024): Promise<Blob> { 35 + return new Promise((resolve, reject) => { 36 + const img = new Image(); 37 + const reader = new FileReader(); 38 + 39 + reader.onload = (e) => { 40 + if (!e.target?.result) { 41 + return reject(new Error('Failed to read file.')); 42 + } 43 + img.src = e.target.result as string; 44 + }; 45 + 46 + reader.onerror = (err) => reject(err); 47 + reader.readAsDataURL(file); 48 + 49 + img.onload = () => { 50 + let width = img.width; 51 + let height = img.height; 52 + const maxDimension = 512; 53 + 54 + if (width > maxDimension || height > maxDimension) { 55 + if (width > height) { 56 + height = Math.round((maxDimension / width) * height); 57 + width = maxDimension; 58 + } else { 59 + width = Math.round((maxDimension / height) * width); 60 + height = maxDimension; 61 + } 62 + } 63 + 64 + console.log('Width:', width); 65 + console.log('Height:', height); 66 + 67 + // Create a canvas to draw the image 68 + const canvas = document.createElement('canvas'); 69 + canvas.width = width; 70 + canvas.height = height; 71 + const ctx = canvas.getContext('2d'); 72 + if (!ctx) return reject(new Error('Failed to get canvas context.')); 73 + ctx.drawImage(img, 0, 0, width, height); 74 + 75 + // Function to try compressing at a given quality 76 + let quality = 1.0; 77 + function attemptCompression() { 78 + canvas.toBlob( 79 + (blob) => { 80 + if (!blob) { 81 + return reject(new Error('Compression failed.')); 82 + } 83 + // If the blob is under our size limit, or quality is too low, resolve it 84 + if (blob.size <= maxSize || quality < 0.5) { 85 + console.log('Compression successful. Blob size:', blob.size); 86 + console.log('Quality:', quality); 87 + resolve(blob); 88 + } else { 89 + // Otherwise, reduce the quality and try again 90 + quality -= 0.1; 91 + attemptCompression(); 92 + } 93 + }, 94 + 'image/jpeg', 95 + quality 96 + ); 97 + } 98 + 99 + attemptCompression(); 100 + }; 101 + 102 + img.onerror = (err) => reject(err); 103 + }); 104 + } 105 + 106 + let images = $state< 107 + { 108 + name: string; 109 + url: string; 110 + }[] 111 + >([]); 112 + 113 + let inputRef = $state<HTMLInputElement | null>(null); 114 + 115 + async function loadImages() { 116 + const names = await getAllImageNames(); 117 + images = ( 118 + await Promise.all( 119 + names.map(async (name) => ({ 120 + name, 121 + url: await getImage(name) 122 + })) 123 + ) 124 + ).sort((a, b) => b.name.localeCompare(a.name)); 125 + } 126 + 127 + function handleDragOver(event: DragEvent) { 128 + event.preventDefault(); 129 + if (event.dataTransfer) { 130 + event.dataTransfer.dropEffect = 'copy'; 131 + } 132 + } 133 + 134 + function handleDrop(event: DragEvent) { 135 + event.preventDefault(); 136 + const file = event.dataTransfer?.files[0]; 137 + if (file) { 138 + handleFileChange({ target: { files: [file] } } as unknown as Event); 139 + } 140 + } 141 + 142 + function handleDragLeave(event: DragEvent) { 143 + event.preventDefault(); 144 + if (event.dataTransfer) { 145 + event.dataTransfer.dropEffect = 'none'; 146 + } 147 + } 148 + 149 + async function handleSelectImage(image: { name: string; url: string }) { 150 + if (editorState.selectedObject) { 151 + editorState.selectedObject.image = 'local:' + image.name; 152 + } 153 + } 154 + </script> 155 + 156 + <Popover.Root> 157 + <Popover.Trigger class={cn('mt-2 cursor-pointer')}> 158 + <div 159 + class={buttonVariants({ 160 + size: 'icon' 161 + })} 162 + > 163 + <svg 164 + xmlns="http://www.w3.org/2000/svg" 165 + fill="none" 166 + viewBox="0 0 24 24" 167 + stroke-width="1.5" 168 + stroke="currentColor" 169 + > 170 + <path 171 + stroke-linecap="round" 172 + stroke-linejoin="round" 173 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 174 + /> 175 + </svg> 176 + </div> 177 + </Popover.Trigger> 178 + <Popover.Content side="left" sideOffset={10}> 179 + <div class="text-base-900 dark:text-base-50 max-w-xl p-4"> 180 + <!-- svelte-ignore a11y_click_events_have_key_events --> 181 + <!-- svelte-ignore a11y_no_static_element_interactions --> 182 + <div 183 + ondragover={handleDragOver} 184 + ondrop={handleDrop} 185 + ondragleave={handleDragLeave} 186 + onclick={() => { 187 + inputRef?.click(); 188 + }} 189 + class="dark:bg-accent-600/5 hover:bg-accent-400/10 dark:hover:bg-accent-600/10 border-accent-400 bg-accent-400/5 dark:border-accent-800 flex h-24 w-full cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed p-4 transition-colors duration-200" 190 + > 191 + <svg 192 + xmlns="http://www.w3.org/2000/svg" 193 + fill="none" 194 + viewBox="0 0 24 24" 195 + stroke-width="2" 196 + stroke="currentColor" 197 + class="text-accent-600 dark:text-accent-400 size-10" 198 + > 199 + <path 200 + stroke-linecap="round" 201 + stroke-linejoin="round" 202 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 203 + /> 204 + </svg> 205 + 206 + <span class="text-accent-600 dark:text-accent-400 font-medium" 207 + >Drag and drop or browse to upload image</span 208 + > 209 + 210 + <input 211 + type="file" 212 + accept="image/*" 213 + onchange={handleFileChange} 214 + class="hidden" 215 + multiple 216 + bind:this={inputRef} 217 + /> 218 + </div> 219 + 220 + <div class="mt-4 grid grid-cols-3 gap-4"> 221 + {#each images as image} 222 + <div 223 + class={cn( 224 + 'border-base-200 dark:border-base-800 relative isolate aspect-square rounded-2xl border object-cover', 225 + editorState.selectedObject?.image === image.url 226 + ? 'border-accent-600 dark:border-accent-400' 227 + : '' 228 + )} 229 + > 230 + <img 231 + src={image.url} 232 + alt="Loaded from IndexedDB" 233 + class="absolute inset-0 h-full w-full overflow-hidden rounded-2xl object-cover" 234 + /> 235 + 236 + <button 237 + onclick={() => { 238 + deleteImage(image.name); 239 + loadImages(); 240 + }} 241 + class="bg-accent-600/10 dark:bg-accent-400/10 text-accent-600 dark:text-accent-400 absolute -top-2 -left-2 z-10 flex size-6 cursor-pointer items-center justify-center rounded-full p-1 backdrop-blur-sm" 242 + > 243 + <svg 244 + xmlns="http://www.w3.org/2000/svg" 245 + fill="none" 246 + viewBox="0 0 24 24" 247 + stroke-width="1.5" 248 + stroke="currentColor" 249 + class="size-6" 250 + > 251 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 252 + </svg> 253 + 254 + <span class="sr-only">Delete</span> 255 + </button> 256 + 257 + <button 258 + onclick={() => handleSelectImage(image)} 259 + class="absolute inset-0 cursor-pointer" 260 + > 261 + <span class="sr-only">select image</span> 262 + </button> 263 + </div> 264 + {/each} 265 + </div> 266 + </div> 267 + </Popover.Content> 268 + </Popover.Root>
+13 -12
src/routes/Scene.svelte
··· 23 23 <T.HemisphereLight args={[0xaaaaaa, 0x444444, 1]} /> 24 24 <T.DirectionalLight args={[0xffffff, 1]} position={[1, 1, 1]} /> 25 25 26 - <RoomObject 27 - onpointerenter={() => { 28 - rotating = true; 29 - }} 30 - onpointerleave={() => { 31 - rotating = false; 32 - }} 33 - {kind} 34 - rotation={[0.5, rotation + 0.5, 0]} 35 - scale={0.4} 36 - colors={['#fbcfe8', '#f472b6', '#db2777', '#9d174d', '#500724']} 37 - /> 26 + <T.Group rotation={[0.5, rotation + 0.5, 0]}> 27 + <RoomObject 28 + onpointerenter={() => { 29 + rotating = true; 30 + }} 31 + onpointerleave={() => { 32 + rotating = false; 33 + }} 34 + {kind} 35 + scale={0.4} 36 + colors={['#fbcfe8', '#f472b6', '#db2777', '#9d174d', '#500724']} 37 + /> 38 + </T.Group>
+231
src/routes/test/+page.svelte
··· 1 + <script lang="ts"> 2 + import { deleteImage, getAllImageNames, getImage, openDB, saveImage } from '$lib/images.svelte'; 3 + import { onMount } from 'svelte'; 4 + 5 + // Open the IndexedDB on component mount 6 + onMount(async () => { 7 + await openDB(); 8 + 9 + loadImages(); 10 + }); 11 + 12 + // Save the selected image 13 + async function handleFileChange(event: Event) { 14 + const target = event.target as HTMLInputElement; 15 + if (target.files) { 16 + for (const file of target.files) { 17 + const compressedFile = await compressImage(file); 18 + saveImage((new Date()).getTime().toString() + crypto.randomUUID(), compressedFile); 19 + } 20 + loadImages(); 21 + } 22 + } 23 + 24 + export function compressImage(file: File, maxSize: number = 100 * 1024): Promise<Blob> { 25 + return new Promise((resolve, reject) => { 26 + const img = new Image(); 27 + const reader = new FileReader(); 28 + 29 + reader.onload = (e) => { 30 + if (!e.target?.result) { 31 + return reject(new Error('Failed to read file.')); 32 + } 33 + img.src = e.target.result as string; 34 + }; 35 + 36 + reader.onerror = (err) => reject(err); 37 + reader.readAsDataURL(file); 38 + 39 + img.onload = () => { 40 + let width = img.width; 41 + let height = img.height; 42 + const maxDimension = 512; 43 + 44 + if (width > maxDimension || height > maxDimension) { 45 + if (width > height) { 46 + height = Math.round((maxDimension / width) * height); 47 + width = maxDimension; 48 + } else { 49 + width = Math.round((maxDimension / height) * width); 50 + height = maxDimension; 51 + } 52 + } 53 + 54 + console.log('Width:', width); 55 + console.log('Height:', height); 56 + 57 + // Create a canvas to draw the image 58 + const canvas = document.createElement('canvas'); 59 + canvas.width = width; 60 + canvas.height = height; 61 + const ctx = canvas.getContext('2d'); 62 + if (!ctx) return reject(new Error('Failed to get canvas context.')); 63 + ctx.drawImage(img, 0, 0, width, height); 64 + 65 + // Function to try compressing at a given quality 66 + let quality = 1.0; 67 + function attemptCompression() { 68 + canvas.toBlob( 69 + (blob) => { 70 + if (!blob) { 71 + return reject(new Error('Compression failed.')); 72 + } 73 + // If the blob is under our size limit, or quality is too low, resolve it 74 + if (blob.size <= maxSize || quality < 0.5) { 75 + console.log('Compression successful. Blob size:', blob.size); 76 + console.log('Quality:', quality); 77 + resolve(blob); 78 + } else { 79 + // Otherwise, reduce the quality and try again 80 + quality -= 0.1; 81 + attemptCompression(); 82 + } 83 + }, 84 + 'image/jpeg', 85 + quality 86 + ); 87 + } 88 + 89 + attemptCompression(); 90 + }; 91 + 92 + img.onerror = (err) => reject(err); 93 + }); 94 + } 95 + 96 + let images = $state<{ 97 + name: string; 98 + url: string; 99 + }[]>([]); 100 + 101 + let inputRef = $state<HTMLInputElement | null>(null); 102 + 103 + async function loadImages() { 104 + const names = await getAllImageNames(); 105 + console.log('Names:', names); 106 + images = (await Promise.all( 107 + names.map(async (name) => ({ 108 + name, 109 + url: await getImage(name) 110 + })) 111 + )).sort((a, b) => b.name.localeCompare(a.name)); 112 + } 113 + 114 + function handleDragOver(event: DragEvent) { 115 + event.preventDefault(); 116 + if (event.dataTransfer) { 117 + event.dataTransfer.dropEffect = 'copy'; 118 + } 119 + } 120 + 121 + function handleDrop(event: DragEvent) { 122 + event.preventDefault(); 123 + const file = event.dataTransfer?.files[0]; 124 + if (file) { 125 + handleFileChange({ target: { files: [file] } } as unknown as Event); 126 + } 127 + } 128 + 129 + function handleDragLeave(event: DragEvent) { 130 + event.preventDefault(); 131 + if (event.dataTransfer) { 132 + event.dataTransfer.dropEffect = 'none'; 133 + } 134 + } 135 + 136 + let selectedImage = $state<{ name: string; url: string } | null>(null); 137 + 138 + function handleSelectImage(image: { name: string; url: string }) { 139 + selectedImage = image; 140 + } 141 + </script> 142 + 143 + <div class="text-base-900 dark:text-base-50 max-w-xl p-4"> 144 + <!-- svelte-ignore a11y_click_events_have_key_events --> 145 + <!-- svelte-ignore a11y_no_static_element_interactions --> 146 + <div 147 + ondragover={handleDragOver} 148 + ondrop={handleDrop} 149 + ondragleave={handleDragLeave} 150 + onclick={() => { 151 + inputRef?.click(); 152 + }} 153 + class="dark:bg-accent-600/5 hover:bg-accent-400/10 dark:hover:bg-accent-600/10 border-accent-400 bg-accent-400/5 dark:border-accent-800 flex h-24 w-full cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed p-4 transition-colors duration-200" 154 + > 155 + <svg 156 + xmlns="http://www.w3.org/2000/svg" 157 + fill="none" 158 + viewBox="0 0 24 24" 159 + stroke-width="1.5" 160 + stroke="currentColor" 161 + class="text-accent-600 dark:text-accent-400 size-6" 162 + > 163 + <path 164 + stroke-linecap="round" 165 + stroke-linejoin="round" 166 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 167 + /> 168 + </svg> 169 + 170 + <span class="text-accent-600 dark:text-accent-400 font-medium" 171 + >Drag and drop or browse to upload image</span 172 + > 173 + 174 + <input 175 + type="file" 176 + accept="image/*" 177 + onchange={handleFileChange} 178 + class="hidden" 179 + multiple 180 + bind:this={inputRef} 181 + /> 182 + </div> 183 + 184 + <div class="mt-4 grid grid-cols-3 gap-4"> 185 + {#each images as image} 186 + <div 187 + class="border-base-200 isolate dark:border-base-800 relative aspect-square rounded-2xl border object-cover" 188 + > 189 + <img 190 + src={image.url} 191 + alt="Loaded from IndexedDB" 192 + class="absolute inset-0 h-full w-full overflow-hidden rounded-2xl object-cover" 193 + /> 194 + 195 + <button 196 + onclick={() => { 197 + deleteImage(image.name); 198 + loadImages(); 199 + }} 200 + class="bg-accent-600/10 z-10 dark:bg-accent-400/10 text-accent-600 dark:text-accent-400 absolute -top-2 -left-2 flex size-6 cursor-pointer items-center justify-center rounded-full p-1 backdrop-blur-sm" 201 + > 202 + <svg 203 + xmlns="http://www.w3.org/2000/svg" 204 + fill="none" 205 + viewBox="0 0 24 24" 206 + stroke-width="1.5" 207 + stroke="currentColor" 208 + class="size-6" 209 + > 210 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 211 + </svg> 212 + 213 + <span class="sr-only">Delete</span> 214 + </button> 215 + 216 + <button 217 + onclick={() => handleSelectImage(image)} 218 + class="absolute inset-0 cursor-pointer" 219 + > 220 + <span class="sr-only">select image</span> 221 + </button> 222 + </div> 223 + {/each} 224 + </div> 225 + 226 + {#if selectedImage} 227 + <div class="mt-4"> 228 + <img src={selectedImage.url} alt="Selected from IndexedDB" /> 229 + </div> 230 + {/if} 231 + </div>
static/models/kaykit-furniture-kit/book_set.glb

This is a binary file and will not be displayed.

static/models/kaykit-furniture-kit/book_single.glb

This is a binary file and will not be displayed.

static/models/kaykit-furniture-kit/pictureframe_large_a.glb

This is a binary file and will not be displayed.

static/models/kaykit-furniture-kit/pictureframe_large_b.glb

This is a binary file and will not be displayed.

static/models/kaykit-furniture-kit/pictureframe_medium.glb

This is a binary file and will not be displayed.

static/models/kaykit-furniture-kit/pictureframe_small_a.glb

This is a binary file and will not be displayed.

static/models/kaykit-furniture-kit/pictureframe_small_b.glb

This is a binary file and will not be displayed.

static/models/kaykit-furniture-kit/pictureframe_small_c.glb

This is a binary file and will not be displayed.

static/models/kaykit-furniture-kit/pictureframe_standing_a.glb

This is a binary file and will not be displayed.

static/models/kaykit-furniture-kit/pictureframe_standing_b.glb

This is a binary file and will not be displayed.

static/models/tiny-treats-bubbly-bathroom/plant.glb

This is a binary file and will not be displayed.

+2 -2
static/script.ts
··· 2 2 import path from 'path'; 3 3 import { execSync } from 'child_process'; 4 4 5 - const rootFolder = './models/kaykit-furniture-kit/'; 5 + const rootFolder = './models/tiny-treats-bubbly-bathroom/'; 6 6 7 7 function convertGLTFFiles(folder: string, serveFrom: string | undefined = undefined) { 8 8 const files = fs.readdirSync(folder, { withFileTypes: true }); ··· 32 32 33 33 console.log('Starting conversion...'); 34 34 // Start the conversion process from the root folder 35 - convertGLTFFiles(rootFolder, base + '/models/kaykit-furniture-kit/'); 35 + convertGLTFFiles(rootFolder, '');