<canvas id="c"></canvas>
<div class="controls">
<div id="js-tray" class="tray">
     <div id="js-tray-slide" class="tray__slide"></div>
 </div>
</div>
<span class="drag-notice" id="js-drag-notice">Drag to rotate 360&#176;</span>
@import url('https://fonts.googleapis.com/css?family=Raleway:500,700&display=swap');

body,
html {
  height: 100%;
  margin: 0;
  padding: 0;
  font-family: 'Raleway', sans-serif;
  font-size: 14px;
  color: #444444;
}
* {
  touch-action: manipulation;
}
*,
*:before,
*:after {
  box-sizing: border-box;
}
body {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  position: fixed;
  top: 0;
  left: 0;
  overflow: hidden;
}

 #c {
  width: 100% !important;
  height: 100% !important;
  display: block;
  top: 0;
  left: 0;
}


.controls {
  position: absolute;
  bottom: 0;
  width: 100%;
}
.options {
  position: absolute;
  left: 0;
}
.option {
  background-size: cover;
  background-position: 50%;
  background-color: white;
  margin-bottom: 3px;
  padding: 10px;
  height: 55px;
  width: 55px;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
}
.option:hover {
  border-left: 5px solid white;
  width: 58px;
}
.option.--is-active {
  border-right: 3px solid red;
  width: 58px;
  cursor: default;
}
.option.--is-active:hover {
  border-left: none;
}
.option img {
  height: 100%;
  width: auto;
  pointer-events: none;
}
.info {
  padding: 0 1em;
  display: flex;
  justify-content: flex-end;
}
.info p {
  margin-top: 0;
}
.tray {
  width: 100%;
  height: 50px;
  position: relative;
  overflow-x: hidden;
}
.tray__slide {
  position: absolute;
  display: flex;
  left: 0;
/*   transform: translateX(-50%);
  animation: wheelin 1s 2s ease-in-out forwards; */
}
.tray__swatch {
  transition: 0.1s ease-in;
  height: 50px;
  min-width: 50px;
  flex: 1;
  box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.3);
  background-size: cover;
  background-position: center;
}
.tray__swatch:nth-child(5n+5) {
  margin-right: 20px;
}
.drag-notice {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 2em;
  width: 10em;
  height: 10em;
  box-sizing: border-box;
  font-size: 0.9em;
  font-weight: 800;
  text-transform: uppercase;
  text-align: center;
  border-radius: 5em;
  background: white;
  position: absolute;
}
.drag-notice.start {
  animation: popout 0.25s 3s forwards;
}
@keyframes popout {
  to {
    transform: scale(0);
  }
}
@keyframes wheelin {
  to {
    transform: translateX(0);
  }
}
@media (max-width: 960px) {
  .options {
    top: 0;
  }
  .info {
    padding: 0 1em 1em 0;
  }
  .info__message {
    display: flex;
    align-items: flex-end;
  }
  .info__message p {
    margin: 0;
    font-size: 0.7em;
  }
}
@media (max-width: 720px) {
  .info {
    flex-direction: column;
    justify-content: center;
    align-items: center;
    padding: 0 1em 1em;
  }
  .info__message {
    margin-bottom: 1em;
  }
}
@media (max-width: 680px) {
  .info {
    padding: 1em 2em;
  }
  .info__message {
    display: none;
  }
  .options {
    bottom: 50px;
  }
  .option {
    margin-bottom: 1px;
    padding: 5px;
    height: 45px;
    width: 45px;
    display: flex;
  }
  .option.--is-active {
    border-right: 2px solid red;
    width: 47px;
  }
  .option img {
    height: 100%;
    width: auto;
    pointer-events: none;
  }
}
/*
  Adapt from this pen.
  https://codepen.io/kylewetton/pen/jONpxpa
*/


const bgColor = 0xf1f1f1;

const elements = ['fence', 'chimney', 'support'];

let loaded = false;
let initRotate = 0;

const originMaterials = {
  fence: null,
  house: null,
  chimney: null
};

const colors = [
  {
    texture: 'https://assets.elecwebmaker.com/texture/paving.jpg',
    size: [2, 2, 2]
  },
  {
    texture: 'https://assets.elecwebmaker.com/texture/marble.jpg',
    size: [2, 2, 2]
  },
  {
    texture: 'https://assets.elecwebmaker.com/texture/metal.jpg',
    size: [2, 2, 2]
  },
  {
    texture: 'https://assets.elecwebmaker.com/texture/tiles.jpg',
    size: [2, 2, 2]
  },
  {
      color: '66533C'
  },
  {
      color: '173A2F'
  },
  {
      color: '153944'
  },
  {
      color: '27548D'
  },
  {
      color: '438AAC'
  }  
];

const tray = document.getElementById('js-tray-slide');

function buildColors(colors) {
  for (let [i, color] of colors.entries()) {
    let swatch = document.createElement('div');
    swatch.classList.add('tray__swatch');
    
    if (color.texture) {
      swatch.style.backgroundImage = "url(" + color.texture + ")";
    } else {
      swatch.style.background = "#" + color.color; 
    }

    swatch.setAttribute('data-key', i);
    tray.append(swatch);
  }
}

buildColors(colors);

const setUpRenderer = () => {
  const canvas = document.querySelector('#c');
  const renderer = new THREE.WebGLRenderer({
    canvas,
    antialias: true
  });
  
  renderer.shadowMap.enabled = true;
  const pixelRatio = window.devicePixelRatio;

  renderer.setSize(window.innerWidth * pixelRatio, window.innerHeight * pixelRatio);  
  
  return {renderer, canvas};
};

const setUpCamera = () => {
  const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000);
  
  const cameraFar = 5;
  
  camera.position.z = cameraFar;
  camera.position.x = 4;
  camera.position.y = 2.5;
  
  return camera;
};

const setUpScene = () => {
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(bgColor);
  scene.fog = new THREE.Fog(bgColor, 20, 100);
  return scene;
};

const setUpLight = () => {
  const light = new THREE.Object3D();
  
  const hemiLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 0.61);
  
  hemiLight.position.set(0, 50, 0);
  
  const dirLight = new THREE.DirectionalLight(0xffffff, 0.54);
  
  dirLight.position.set(-8, 12, 8);
  dirLight.castShadow = true;
  dirLight.shadow.mapSize = new THREE.Vector2(1024, 1024);
  
  light.add(hemiLight);
  light.add(dirLight);
  
  return light;
};

const loadModel = () => {
  const modelPath = 'https://assets.elecwebmaker.com/house/scene.gltf?q=' + new Date();
  
  const loader = new THREE.GLTFLoader();
  
  return new Promise((resolve, reject) => {
   loader.load(modelPath, (gltf) => {
    gltf.scene.scale.set(.3, .3, .3);
     gltf.scene.translateY(-1);
    const ground = gltf.scene.getObjectByName('ground').children[0];
     
    const fence = gltf.scene.getObjectByName('fence').children[0];
     
    const house = gltf.scene.getObjectByName('house').children[0];
    const chimney = gltf.scene.getObjectByName('chimney').children[0];
     
    elements.forEach((elementName) => {
      const element = gltf.scene.getObjectByName(elementName).children[0];
      originMaterials[elementName] = element.material;
      element.material = originMaterials[elementName].clone();
    });
     
    const threes = gltf.scene.getObjectByName('Trees_GRP');
     
    fence.castShadow = true;
    threes.children.forEach(treeGroup => {
      const treeMesh = treeGroup.children[0];
      treeMesh.castShadow = true;
    });    
    house.castShadow = true;
    
    ground.receiveShadow = true;
     
    resolve({
      model: gltf.scene,
      parts: {
        fence,
        house
      }
    });   
   }, undefined, reject);
  });
};

const updateTexture = (activeElement, textureUrl, sizes) => {
  const texture = new THREE.TextureLoader().load(textureUrl + '?q=' + new Date());
  texture.repeat.set(sizes[0], sizes[1], sizes[2]);

  texture.wrapS = THREE.RepeatWrapping;
  texture.wrapT = THREE.RepeatWrapping;

  activeElement.material.map = texture;
  activeElement.material.color.set('#ffffff');
};

const updateColor = (activeElement, color) => {
  if (activeElement.parent.name === elements[0]) {
    if (color.texture) {
      updateTexture(activeElement, color.texture, color.size);
    } else {
      activeElement.material.color.set(parseInt('0x' + color.color)); 
    }
  } else if (activeElement.parent.name === elements[1]) {
    if (color.texture) {
      updateTexture(activeElement, color.texture, color.size);
    } else {
      activeElement.material.map = null;
      activeElement.material.color.set(parseInt('0x' + color.color));      
    }
  }else {
    if (color.texture) {
      updateTexture(activeElement, color.texture, color.size);
    } else {
      activeElement.material.map = null;
      activeElement.material.color.set(parseInt('0x' + color.color));
    }
  }
};

const main = async () => {
  const scene = setUpScene();

  const {renderer, canvas} = setUpRenderer();

  const camera = setUpCamera();
  
  const cameraControls = new THREE.OrbitControls(camera, renderer.domElement);
  cameraControls.maxPolarAngle = Math.PI / 2;
  cameraControls.minPolarAngle = Math.PI / 3;
  cameraControls.enableDamping = true;
  cameraControls.dampingFactor = .1;
  cameraControls.enablePan = false;
  cameraControls.autoRotate = true;
  cameraControls.autoRotateSpeed = 0.2;

  
  const {model, parts } = await loadModel();
  
  scene.add(model); 
  
  const light = setUpLight();
  
  scene.add(light);
  
  const swatches = document.querySelectorAll(".tray__swatch");
  const traySlide = document.querySelector('.tray__slide');
  
  let activeElement;
  let hoverElement;
  
  const dragNotice = document.getElementById('js-drag-notice');
  
  const selectSwatch = (e) => {
    const color = colors[parseInt(e.target.dataset.key)];
    
    if (activeElement) {
      updateColor(activeElement, color);
      originMaterials[activeElement.parent.name] = activeElement.material; 
    }
  };

  for (const swatch of swatches) {
    swatch.addEventListener('click', (event) => selectSwatch(event));
  }

  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    const pixelRatio = window.devicePixelRatio;

    renderer.setSize(window.innerWidth * pixelRatio, window.innerHeight * pixelRatio);  
  });
  
  const rayCaster = new THREE.Raycaster();
  let mouse = new THREE.Vector2();
  
  window.addEventListener('mousemove', (event) => {
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = - (event.clientY / window.innerHeight) * 2 + 1;
  });

  canvas.addEventListener('mousedown', (event) => {
    activeElement = hoverElement;
  });
  
  const initialRotation = () => {
    initRotate++;
    if (initRotate <= 120) {
      model.rotation.y += Math.PI / 60;
    } else {
      loaded = true;
    }
  };
  
  const animate = () => {
    renderer.render(scene, camera);
    cameraControls.update();
    
    rayCaster.setFromCamera(mouse, camera);
    
    const intersects = rayCaster.intersectObjects(scene.children, true);
    
    elements.forEach((elementName) => {
      const elementMesh = scene.getObjectByName(elementName).children[0];
      elementMesh.material = originMaterials[elementName].clone();
    });
    
    if(intersects.length > 0) {
      // console.log('intersects[0].object;', intersects[0].object.parent); 
    }
    
    if(intersects.length > 0 && elements.includes(intersects[0].object.parent.name)) {
      intersects[0].object.material.color.set('#00ff00');
      hoverElement = intersects[0].object;
    } else {
      hoverElement = null;
    };
    
    if (model != null && loaded == false) {
      initialRotation();
      dragNotice.classList.add('start');
    }
    
    if (activeElement) {
      traySlide.style.border = '2px solid #f47a7a';
    } else {
      traySlide.style.border = '2px solid transparent';
    }
    
    requestAnimationFrame(animate);
  };
  
  
  animate();
};

main();
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdn.jsdelivr.net/npm/three@0.113.2/build/three.min.js
  2. https://cdn.jsdelivr.net/npm/three@0.113.2/examples/js/controls/OrbitControls.js
  3. https://cdn.jsdelivr.net/npm/three@0.113.2/examples/js/loaders/GLTFLoader.js