diff --git a/src/core/formats/WdbParser.js b/src/core/formats/WdbParser.js index b5645b6..66eaf50 100644 --- a/src/core/formats/WdbParser.js +++ b/src/core/formats/WdbParser.js @@ -37,10 +37,11 @@ export class WdbParser { const nameLen = this.reader.readS32(); const name = this.reader.readString(nameLen).replace(/\0/g, ''); - // Parse parts (skip for now) + // Parse parts const numParts = this.reader.readS32(); + const parts = []; for (let i = 0; i < numParts; i++) { - this.skipPartReference(); + parts.push(this.parsePartReference()); } // Parse models @@ -50,14 +51,15 @@ export class WdbParser { models.push(this.parseModelEntry()); } - return { name, numParts, models }; + return { name, numParts, parts, models }; } - skipPartReference() { + parsePartReference() { const nameLen = this.reader.readU32(); - this.reader.skip(nameLen); // name - this.reader.skip(4); // data_length - this.reader.skip(4); // data_offset + const name = this.reader.readString(nameLen).replace(/\0/g, ''); + const dataLength = this.reader.readU32(); + const dataOffset = this.reader.readU32(); + return { name, dataLength, dataOffset }; } parseModelEntry() { @@ -90,6 +92,39 @@ export class WdbParser { return this.reader.readString(length).replace(/\0/g, ''); } + /** + * Parse part data blob at specified offset + * Parts have a simpler structure than models - no animation, direct LOD data + * @param {number} offset - Absolute file offset + * @returns {{ parts: Array, textures: Array }} + */ + parsePartData(offset) { + this.reader.seek(offset); + + const textureInfoOffset = this.reader.readU32(); + const numRois = this.reader.readU32(); + const parts = []; + + for (let i = 0; i < numRois; i++) { + const nameLen = this.reader.readU32(); + const name = this.readCleanString(nameLen); + const numLods = this.reader.readU32(); + const roiInfoOffset = this.reader.readU32(); + + const lods = []; + for (let j = 0; j < numLods; j++) { + lods.push(this.parseLod()); + } + + parts.push({ name, lods }); + } + + this.reader.seek(offset + textureInfoOffset); + const textures = this.parseTextureInfo(); + + return { parts, textures }; + } + /** * Parse model_data blob at specified offset * @param {number} offset - Absolute file offset @@ -112,9 +147,8 @@ export class WdbParser { // Parse ROI hierarchy const roi = this.parseRoi(); - // Parse textures at textureInfoOffset this.reader.seek(offset + textureInfoOffset); - const textures = this.parseTextureInfo(); + const textures = this.parseTextureInfo(true); // Models have skipTextures field return { version, anim, roi, textures }; } @@ -264,7 +298,7 @@ export class WdbParser { children.push(this.parseRoi()); } - return { name, boundingSphere, boundingBox, textureName, lods, children }; + return { name, boundingSphere, boundingBox, textureName, sharedLodList: sharedLodList !== 0, lods, children }; } parseLod() { @@ -366,9 +400,19 @@ export class WdbParser { return { color, alpha, shading, useAlias, textureName, materialName }; } - parseTextureInfo() { + /** + * Parse texture info block + * @param {boolean} isModel - If true, read skipTextures field (models have it, parts don't) + */ + parseTextureInfo(isModel = false) { const numTextures = this.reader.readU32(); - const skipTextures = this.reader.readU32(); + + // Models have an extra skipTextures field that parts don't have + // See legomodelpresenter.cpp vs legopartpresenter.cpp in LEGO1 source + if (isModel) { + this.reader.readU32(); // skipTextures - skip over this field + } + const textures = []; for (let i = 0; i < numTextures; i++) { @@ -437,3 +481,48 @@ export function findRoi(roi, name) { } return null; } + +/** + * Resolve LODs for an ROI, handling shared LOD lists + * This mirrors how the game's ViewLODListManager resolves shared parts + * @param {object} roi - ROI data with lods and sharedLodList flag + * @param {Map} partsMap - Map of part name (lowercase) -> part data with lods + * @returns {Array} - Array of LODs (may be empty) + */ +export function resolveLods(roi, partsMap) { + // If ROI has its own LODs, use them + if (roi.lods && roi.lods.length > 0) { + return roi.lods; + } + + // If ROI uses shared LOD list, look up by name (strip trailing digits) + // This matches the game's logic in LegoROI::Read + if (roi.sharedLodList && roi.name && partsMap) { + const baseName = roi.name.replace(/\d+$/, '').toLowerCase(); + const part = partsMap.get(baseName); + if (part && part.lods && part.lods.length > 0) { + return part.lods; + } + } + + return []; +} + +/** + * Build a parts lookup map from a world's parts array + * @param {WdbParser} parser - Parser instance for reading part data + * @param {Array} worldParts - Array of part references from world entry + * @returns {Map} - Map of part name (lowercase) -> part data + */ +export function buildPartsMap(parser, worldParts) { + const partsMap = new Map(); + if (!worldParts || worldParts.length === 0) return partsMap; + + for (const partRef of worldParts) { + const partData = parser.parsePartData(partRef.dataOffset); + for (const part of partData.parts) { + partsMap.set(part.name.toLowerCase(), part); + } + } + return partsMap; +} diff --git a/src/core/rendering/VehiclePartRenderer.js b/src/core/rendering/VehiclePartRenderer.js new file mode 100644 index 0000000..477f81f --- /dev/null +++ b/src/core/rendering/VehiclePartRenderer.js @@ -0,0 +1,339 @@ +import * as THREE from 'three'; +import { LegoColors } from '../savegame/constants.js'; +import { resolveLods } from '../formats/WdbParser.js'; + +/** + * Specialized renderer for LEGO vehicle parts + * Renders ROI with proper textures - only colors meshes with INH prefix in textureName/materialName + */ +export class VehiclePartRenderer { + constructor(canvas) { + this.canvas = canvas; + this.animating = false; + this.modelGroup = null; + this.colorableMeshes = []; // Meshes with INH prefix + this.textures = new Map(); // Cache for loaded textures + + this.scene = new THREE.Scene(); + + this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); + this.camera.position.set(0, 0, 3); + this.camera.lookAt(0, 0, 0); + + this.renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true + }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.setClearColor(0x000000, 0); + + this.setupLighting(); + } + + setupLighting() { + const ambient = new THREE.AmbientLight(0xffffff, 0.8); + this.scene.add(ambient); + + const sunLight = new THREE.DirectionalLight(0xffffff, 0.6); + sunLight.position.set(1, 2, 3); + this.scene.add(sunLight); + } + + /** + * Check if a mesh has INH prefix in textureName or materialName + * This indicates the mesh should inherit color from the ROI + */ + hasInhPrefix(mesh) { + const texName = mesh.properties?.textureName?.toLowerCase() || ''; + const matName = mesh.properties?.materialName?.toLowerCase() || ''; + return texName.startsWith('inh') || matName.startsWith('inh'); + } + + /** + * Create a Three.js texture from parsed texture data + */ + createTexture(textureData) { + const canvas = document.createElement('canvas'); + canvas.width = textureData.width; + canvas.height = textureData.height; + const ctx = canvas.getContext('2d'); + + const imageData = ctx.createImageData(textureData.width, textureData.height); + for (let i = 0; i < textureData.pixels.length; i++) { + const colorIdx = textureData.pixels[i]; + const color = textureData.palette[colorIdx] || { r: 0, g: 0, b: 0 }; + imageData.data[i * 4 + 0] = color.r; + imageData.data[i * 4 + 1] = color.g; + imageData.data[i * 4 + 2] = color.b; + imageData.data[i * 4 + 3] = 255; + } + ctx.putImageData(imageData, 0, 0); + + const texture = new THREE.CanvasTexture(canvas); + texture.minFilter = THREE.NearestFilter; + texture.magFilter = THREE.NearestFilter; + return texture; + } + + /** + * Load part geometry with proper textures and colorable mesh detection + * @param {object} roiData - Parsed ROI data with lods + * @param {string} colorName - LEGO color name for colorable parts + * @param {object[]} textureList - Array of texture data from model + * @param {Map} partsMap - Map of part name -> part data for shared LOD resolution + */ + loadPartWithColor(roiData, colorName, textureList = [], partsMap = new Map()) { + this.clearModel(); + + this.modelGroup = new THREE.Group(); + this.colorableMeshes = []; + this.partsMap = partsMap; + + // Build texture lookup map (case-insensitive) + this.textures.clear(); + for (const tex of textureList) { + if (tex.name) { + this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); + } + } + + const legoColor = LegoColors[colorName] || LegoColors['lego red']; + const threeLegoColor = new THREE.Color(legoColor.r / 255, legoColor.g / 255, legoColor.b / 255); + + this.createMeshesFromROI(roiData, threeLegoColor); + + this.centerAndScaleModel(); + + this.scene.add(this.modelGroup); + this.renderer.render(this.scene, this.camera); + } + + /** + * Recursively create meshes from ROI and its children + */ + createMeshesFromROI(roiData, legoColor) { + const lods = resolveLods(roiData, this.partsMap); + + if (lods.length > 0) { + // Use highest quality LOD (last in array has most vertices) + const lod = lods[lods.length - 1]; + + for (const mesh of lod.meshes) { + const geometry = this.createGeometry(mesh, lod); + if (!geometry) continue; + + const isColorable = this.hasInhPrefix(mesh); + const hasUVs = mesh.textureIndices && mesh.textureIndices.length > 0; + const meshTextureName = mesh.properties?.textureName?.toLowerCase(); + + let material; + + // Get alpha from mesh properties + // In the original game: alpha = 0 means opaque, alpha > 0 means transparent + const meshAlpha = mesh.properties?.alpha || 0; + const isTransparent = meshAlpha > 0; + const opacity = isTransparent ? meshAlpha : 1; + + if (isColorable) { + // Mesh has INH prefix - use the LEGO color + material = new THREE.MeshLambertMaterial({ + color: legoColor, + side: THREE.DoubleSide, + transparent: isTransparent, + opacity: opacity, + depthWrite: !isTransparent + }); + this.colorableMeshes.push(null); // Placeholder, will set after mesh creation + } else if (hasUVs && meshTextureName && this.textures.has(meshTextureName)) { + // Mesh has its own texture + material = new THREE.MeshLambertMaterial({ + map: this.textures.get(meshTextureName), + side: THREE.DoubleSide, + transparent: isTransparent, + opacity: opacity, + depthWrite: !isTransparent + }); + } else { + // Fallback to mesh's vertex color + const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 }; + material = new THREE.MeshLambertMaterial({ + color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255), + side: THREE.DoubleSide, + transparent: isTransparent, + opacity: opacity, + depthWrite: !isTransparent + }); + } + + const threeMesh = new THREE.Mesh(geometry, material); + this.modelGroup.add(threeMesh); + + // Track colorable meshes + if (isColorable) { + this.colorableMeshes[this.colorableMeshes.length - 1] = threeMesh; + } + } + } + + // Process children recursively + for (const child of roiData.children || []) { + this.createMeshesFromROI(child, legoColor); + } + } + + /** + * Create a single geometry from mesh data + */ + createGeometry(mesh, lod) { + if (!mesh.polygonIndices || mesh.polygonIndices.length === 0) { + return null; + } + + const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0; + + const vertexIndicesPacked = []; + for (const poly of mesh.polygonIndices) { + vertexIndicesPacked.push(poly.a, poly.b, poly.c); + } + + const textureIndicesFlat = []; + if (hasTexture) { + for (const texPoly of mesh.textureIndices) { + textureIndicesFlat.push(texPoly.a, texPoly.b, texPoly.c); + } + } + + const meshVertices = []; + const meshNormals = []; + const meshUvs = []; + const indices = []; + + for (let i = 0; i < vertexIndicesPacked.length; i++) { + const packed = vertexIndicesPacked[i]; + + if ((packed & 0x80000000) !== 0) { + indices.push(meshVertices.length); + + const gv = packed & 0xFFFF; + const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 }; + meshVertices.push([-v.x, v.y, v.z]); + + const gn = (packed >>> 16) & 0x7fff; + const n = lod.normals[gn] || { x: 0, y: 1, z: 0 }; + meshNormals.push([-n.x, n.y, n.z]); + + if (hasTexture && lod.textureVertices && lod.textureVertices.length > 0) { + const tex = textureIndicesFlat[i]; + const uv = lod.textureVertices[tex] || { u: 0, v: 0 }; + meshUvs.push([uv.u, 1 - uv.v]); + } + } else { + indices.push(packed & 0xFFFF); + } + } + + // Reverse face winding + for (let i = 0; i < indices.length; i += 3) { + const temp = indices[i]; + indices[i] = indices[i + 2]; + indices[i + 2] = temp; + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(meshVertices.flat(), 3)); + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(meshNormals.flat(), 3)); + geometry.setIndex(indices); + + if (hasTexture && meshUvs.length > 0) { + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(meshUvs.flat(), 2)); + } + + return geometry; + } + + /** + * Update color of colorable meshes without reloading geometry + */ + updateColor(colorName) { + if (!this.modelGroup || this.colorableMeshes.length === 0) return; + + const legoColor = LegoColors[colorName] || LegoColors['lego red']; + const threeColor = new THREE.Color(legoColor.r / 255, legoColor.g / 255, legoColor.b / 255); + + for (const mesh of this.colorableMeshes) { + if (mesh && mesh.material) { + mesh.material.color = threeColor; + } + } + + this.renderer.render(this.scene, this.camera); + } + + centerAndScaleModel() { + if (!this.modelGroup) return; + + const box = new THREE.Box3().setFromObject(this.modelGroup); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + + this.modelGroup.position.sub(center); + + const maxDim = Math.max(size.x, size.y, size.z); + if (maxDim > 0) { + const scale = 1.5 / maxDim; + this.modelGroup.scale.setScalar(scale); + } + } + + clearModel() { + if (this.modelGroup) { + this.modelGroup.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.geometry?.dispose(); + child.material?.dispose(); + } + }); + this.scene.remove(this.modelGroup); + this.modelGroup = null; + } + this.colorableMeshes = []; + + for (const texture of this.textures.values()) { + texture.dispose(); + } + this.textures.clear(); + } + + start() { + this.animating = true; + this.animate(); + } + + stop() { + this.animating = false; + } + + animate = () => { + if (!this.animating) return; + requestAnimationFrame(this.animate); + + if (this.modelGroup) { + this.modelGroup.rotation.y += 0.01; + } + + this.renderer.render(this.scene, this.camera); + } + + resize(width, height) { + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(width, height, false); + } + + dispose() { + this.animating = false; + this.clearModel(); + this.renderer?.dispose(); + } +} diff --git a/src/core/rendering/WdbModelRenderer.js b/src/core/rendering/WdbModelRenderer.js index 675e3f6..26f47c9 100644 --- a/src/core/rendering/WdbModelRenderer.js +++ b/src/core/rendering/WdbModelRenderer.js @@ -35,14 +35,12 @@ export class WdbModelRenderer { * Setup scene lighting - override to customize */ setupLighting() { - // Flat, even lighting similar to in-game - const ambient = new THREE.AmbientLight(0xffffff, 1.5); + const ambient = new THREE.AmbientLight(0xffffff, 0.8); this.scene.add(ambient); - // Soft front light - const frontLight = new THREE.DirectionalLight(0xffffff, 0.3); - frontLight.position.set(0, 0, 5); - this.scene.add(frontLight); + const sunLight = new THREE.DirectionalLight(0xffffff, 0.6); + sunLight.position.set(1, 2, 3); + this.scene.add(sunLight); } /** @@ -62,22 +60,18 @@ export class WdbModelRenderer { this.texture.magFilter = THREE.LinearFilter; if (texturedGeometry) { - const texturedMaterial = new THREE.MeshStandardMaterial({ + const texturedMaterial = new THREE.MeshLambertMaterial({ map: this.texture, - side: THREE.DoubleSide, - roughness: 0.8, - metalness: 0.1 + side: THREE.DoubleSide }); this.texturedMesh = new THREE.Mesh(texturedGeometry, texturedMaterial); this.modelGroup.add(this.texturedMesh); } for (const { geometry, color } of nonTexturedGeometries) { - const material = new THREE.MeshStandardMaterial({ + const material = new THREE.MeshLambertMaterial({ color: new THREE.Color(color.r / 255, color.g / 255, color.b / 255), - side: THREE.DoubleSide, - roughness: 0.8, - metalness: 0.1 + side: THREE.DoubleSide }); const mesh = new THREE.Mesh(geometry, material); this.modelGroup.add(mesh); @@ -126,7 +120,6 @@ export class WdbModelRenderer { } } - // Build mesh vertices following brickolini-island logic const meshVertices = []; const meshNormals = []; const meshUvs = []; diff --git a/src/core/savegame/constants.js b/src/core/savegame/constants.js index ad93c43..42cf79e 100644 --- a/src/core/savegame/constants.js +++ b/src/core/savegame/constants.js @@ -189,3 +189,95 @@ export const HISTORY_FILE = 'History.gsi'; export function getSaveFileName(slot) { return `G${slot}.GS`; } + +// LEGO brick colors (from legoroi.cpp) +export const LegoColors = Object.freeze({ + 'lego black': { r: 0x21, g: 0x21, b: 0x21 }, + 'lego blue': { r: 0x00, g: 0x54, b: 0x8c }, + 'lego green': { r: 0x00, g: 0x78, b: 0x2d }, + 'lego red': { r: 0xcb, g: 0x12, b: 0x20 }, + 'lego white': { r: 0xfa, g: 0xfa, b: 0xfa }, + 'lego yellow': { r: 0xff, g: 0xb9, b: 0x00 } +}); + +// LEGO color display names and order +export const LegoColorNames = ['lego black', 'lego blue', 'lego green', 'lego red', 'lego white', 'lego yellow']; + +// Vehicle build world names in WDB +export const VehicleWorlds = Object.freeze({ + dunebuggy: 'BLDD', + helicopter: 'BLDH', + jetski: 'BLDJ', + racecar: 'BLDR' +}); + +// Vehicle model names within each world +export const VehicleModels = Object.freeze({ + dunebuggy: 'Dunebld', + helicopter: 'Chptrbld', + jetski: 'Jetbld', + racecar: 'bldrace' +}); + +// Vehicle display names +export const VehicleNames = Object.freeze({ + dunebuggy: 'Dune Buggy', + helicopter: 'Helicopter', + jetski: 'Jetski', + racecar: 'Race Car' +}); + +// Vehicle part color definitions - 43 parts total (from legogamestate.cpp) +export const VehiclePartColors = Object.freeze({ + dunebuggy: [ + { part: 'dbbkfny0', variable: 'c_dbbkfny0', label: 'Back Fender', defaultColor: 'lego red' }, + { part: 'dbbkxlY0', variable: 'c_dbbkxly0', label: 'Back Axle', defaultColor: 'lego white' }, + { part: 'dbfbrdY0', variable: 'c_dbfbrdy0', label: 'Body', defaultColor: 'lego red' }, + { part: 'dbflagY0', variable: 'c_dbflagy0', label: 'Flag', defaultColor: 'lego yellow' }, + { part: 'dbfrfny4', variable: 'c_dbfrfny4', label: 'Front Fender', defaultColor: 'lego red' }, + { part: 'dbfrxlY0', variable: 'c_dbfrxly0', label: 'Front Axle', defaultColor: 'lego white' }, + { part: 'dbhndln0', variable: 'c_dbhndly0', label: 'Handlebar', defaultColor: 'lego white' }, + { part: 'dbltbrY0', variable: 'c_dbltbry0', label: 'Rear Lights', defaultColor: 'lego white' } + ], + helicopter: [ + { part: 'chbasey0', variable: 'c_chbasey0', label: 'Base', defaultColor: 'lego black' }, + { part: 'chbacky0', variable: 'c_chbacky0', label: 'Back', defaultColor: 'lego black' }, + { part: 'chdishy0', variable: 'c_chdishy0', label: 'Dish', defaultColor: 'lego white' }, + { part: 'chhorny0', variable: 'c_chhorny0', label: 'Horn', defaultColor: 'lego black' }, + { part: 'chljety1', variable: 'c_chljety1', label: 'Left Jet', defaultColor: 'lego black' }, + { part: 'chrjety1', variable: 'c_chrjety1', label: 'Right Jet', defaultColor: 'lego black' }, + { part: 'chmidly0', variable: 'c_chmidly0', label: 'Middle', defaultColor: 'lego black' }, + { part: 'chmotry0', variable: 'c_chmotry0', label: 'Motor', defaultColor: 'lego blue' }, + { part: 'chsidly0', variable: 'c_chsidly0', label: 'Left Side', defaultColor: 'lego black' }, + { part: 'chsidry0', variable: 'c_chsidry0', label: 'Right Side', defaultColor: 'lego black' }, + { part: 'chstuty0', variable: 'c_chstuty0', label: 'Skids', defaultColor: 'lego black' }, + { part: 'chtaily0', variable: 'c_chtaily0', label: 'Tail', defaultColor: 'lego black' }, + { part: 'chwindy1', variable: 'c_chwindy1', label: 'Windshield', defaultColor: 'lego black' }, + { part: 'chblady0', variable: 'c_chblady0', label: 'Blades', defaultColor: 'lego black' }, + { part: 'chseaty0', variable: 'c_chseaty0', label: 'Seat', defaultColor: 'lego white' } + ], + jetski: [ + { part: 'jsdashy0', variable: 'c_jsdashy0', label: 'Dashboard', defaultColor: 'lego white' }, + { part: 'jsexhy0', variable: 'c_jsexhy0', label: 'Exhaust', defaultColor: 'lego black' }, + { part: 'jsfrnty5', variable: 'c_jsfrnty5', label: 'Front', defaultColor: 'lego black' }, + { part: 'jshndln0', variable: 'c_jshndly0', label: 'Handlebar', defaultColor: 'lego red' }, + { part: 'jslsidy0', variable: 'c_jslsidy0', label: 'Left Side', defaultColor: 'lego black' }, + { part: 'jsrsidy0', variable: 'c_jsrsidy0', label: 'Right Side', defaultColor: 'lego black' }, + { part: 'jsskiby0', variable: 'c_jsskiby0', label: 'Ski Body', defaultColor: 'lego red' }, + { part: 'jswnshy5', variable: 'c_jswnshy5', label: 'Windshield', defaultColor: 'lego white' }, + { part: 'jsbasey0', variable: 'c_jsbasey0', label: 'Base', defaultColor: 'lego white' } + ], + racecar: [ + { part: 'rcbacky6', variable: 'c_rcbacky6', label: 'Back', defaultColor: 'lego green' }, + { part: 'rcedgey0', variable: 'c_rcedgey0', label: 'Edge', defaultColor: 'lego green' }, + { part: 'rcfrmey0', variable: 'c_rcfrmey0', label: 'Frame', defaultColor: 'lego red' }, + { part: 'rcfrnty6', variable: 'c_rcfrnty6', label: 'Front', defaultColor: 'lego green' }, + { part: 'rcmotry0', variable: 'c_rcmotry0', label: 'Motor', defaultColor: 'lego white' }, + { part: 'rcsidey0', variable: 'c_rcsidey0', label: 'Side', defaultColor: 'lego green' }, + { part: 'rcstery0', variable: 'c_rcstery0', label: 'Steering Wheel', defaultColor: 'lego white' }, + { part: 'rcstrpy0', variable: 'c_rcstrpy0', label: 'Stripe', defaultColor: 'lego yellow' }, + { part: 'rctailya', variable: 'c_rctailya', label: 'Tail', defaultColor: 'lego white' }, + { part: 'rcwhl1y0', variable: 'c_rcwhl1y0', label: 'Wheels 1', defaultColor: 'lego white' }, + { part: 'rcwhl2y0', variable: 'c_rcwhl2y0', label: 'Wheels 2', defaultColor: 'lego white' } + ] +}); diff --git a/src/lib/Carousel.svelte b/src/lib/Carousel.svelte index c0804fb..9e03675 100644 --- a/src/lib/Carousel.svelte +++ b/src/lib/Carousel.svelte @@ -1,5 +1,6 @@