Tour 3D quanh Nhà B1 SoICT

Xây dựng tour 3D tương tác quanh Nhà B1 bằng Three.js.

Introduction

30 Second Summary

Finding your way around an unfamiliar campus building can be difficult when a flat map gives you little sense of height or orientation. A first visit becomes easier when you can inspect the area before you arrive.

In this project, you will build a browser-based 3D tour around Nhà B1 using Three.js. Visitors can move around the building before focusing on two verified locations inside it.

What You'll Build

You will open a local tour where a ten-level Nhà B1 model stands in a stylized neighbourhood with two coloured markers ready to explore.

By the end of this project, you'll have:

  • A ten-level Nhà B1 scene you can show from any angle against conceptual roads and greenery.
  • Responsive camera controls that let you orbit around Nhà B1. Pan and zoom inputs help you inspect the model on wide or narrow screens. A guided orbit presents the area before the reset control restores the overview.
  • Clickable information markers for two verified locations inside Nhà B1. One marker identifies Văn phòng Trường CNTT&TT at Phòng 505. The other identifies Trung tâm Nghiên cứu Quốc tế về Trí tuệ nhân tạo at Tầng 10. Selecting a marker opens its information panel. The camera moves to the selected point.
  • Secret Mission: Audit the stylized model against permitted reference views. Turn your findings into a ranked correction backlog for the next campus-map project.

Are there any prerequisites?

You need basic JavaScript knowledge. The walkthrough provides every local tool and requires no prior 3D programming experience.

Before We Start

Before any hands-on work begins, commit to the smallest useful slice of your future campus map. This browser-based MVP focuses on a stylized 3D tour around Nhà B1 with orbit navigation plus verified points for the SoICT office in Room 505 and BKAI on Floor 10.

Set Up the Verified Three.js Project

The B1 tour needs a local foundation that behaves the same way every time you run it. A reproducible setup keeps later 3D work focused on the scene instead of environment problems.

You'll edit the project in VSCodium. A compatible Node.js runtime lets Vite serve the pinned Three.js dependency.

In this step, get ready to:
  • Install the open-source editor used for the B1 tour.
  • Verify that Node.js meets the Vite requirement.
  • Create the project files before running the local development server.
Install the open-source editor

VSCodium publishes freely licensed binaries with telemetry disabled. Version 1.135.06055 gives this project a consistent editor on Linux.

Choose the tab that matches the VSCodium state on your computer.

✔️ I have VSCodium 1.135.06055

Your editor already matches the project version. Continue below to open it.

ⓧ I have an older VSCodium version

Update your existing installation with the official Linux package for version 1.135.06055.

Your software installer may request administrator approval. This approval only installs the editor on your computer.

  • Download the package for your Linux distribution from the official VSCodium release.
  • Install the downloaded package with your Linux distribution's software installer.

You'll see VSCodium available in your Linux application list after the update finishes.

Package installation failed?

Confirm that the package matches your Linux distribution. Check that its architecture matches your computer.

If the installer still fails, help me diagnose the VSCodium package installation.

ⓧ VSCodium is not installed

Choose the installation method supported by your Linux distribution.

Flatpak

  • Install VSCodium through Flathub by running this command:
flatpak install flathub com.vscodium.codium
What does this command do?

Flatpak downloads the VSCodium application from Flathub. It registers the application with your Linux desktop.

You'll see VSCodium in your Linux application list when the installation completes.

Flatpak installation failed?

Confirm that Flatpak is installed. Confirm that Flathub is enabled for your computer.

If the command still fails, help me troubleshoot the Flatpak installation.

Snap

  • Install VSCodium through Snap by running this command:
snap install codium --classic
What does this command do?

Snap downloads the Codium package. The classic option gives the editor access to your local project files.

You'll see VSCodium in your Linux application list when the installation completes.

Snap installation failed?

Confirm that Snap support is available on your Linux distribution. Your account may need permission to install system applications.

If the command still fails, help me troubleshoot the Snap installation.

Official Linux package

Your software installer may request administrator approval. This approval only installs the editor on your computer.

  • Download the package for your Linux distribution from the official VSCodium release.
  • Install the downloaded package with your Linux distribution's software installer.

You'll see VSCodium available in your Linux application list when the installation finishes.

Package installation failed?

Confirm that the package format matches your Linux distribution. Check that its architecture matches your computer.

If you need help choosing the package, help me identify the correct VSCodium download.

  • Open your Linux application search.
  • Type VSCodium into the search field.
  • Press Enter to launch VSCodium.

VSCodium opens to its editor window. Your open-source workspace is ready for the project.

Why VSCodium?

VSCodium distributes MIT-licensed editor binaries. Telemetry is disabled in those binaries.

This keeps the prescribed editor aligned with the project's free and open-source toolchain.

VSCodium does not open?

Restart your Linux application search after installation. Some desktop environments refresh their application list after you sign out.

If VSCodium remains unavailable, help me find the installed VSCodium application.

Verify Node.js and npm

Vite requires a compatible Node.js version before it can serve the tour. The version checks reveal whether your existing runtime is ready or needs an upgrade.

  • Select Terminal from the VSCodium menu bar.
  • Select New Terminal.
  • Check the installed Node.js version plus the npm version by running:
node -v
npm -v

What do these commands check?

The first command prints your installed Node.js version. The second command confirms that the npm CLI is available.

Vite documents a Node.js requirement of 20.19+ or 22.12+.

Choose the tab that matches the output in your terminal.

✔️ I see a compatible Node.js version

Your first command reports a version that satisfies the Vite requirement. Your second command prints an npm version number.

Good progress. Your existing runtime can now install the pinned project dependencies.

ⓧ I see an older Node.js version

The installed runtime is below the Vite requirement. Use nvm 0.40.8 to install Node.js 24.21.0 LTS.

  • Install nvm 0.40.8 by running this command:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash

What does this command do?

The command downloads the official nvm 0.40.8 installation script. Bash runs that script to add nvm to your shell environment.

nvm installation failed?

Confirm that your computer has internet access. Check whether your terminal can run curl.

If the installation still fails, help me troubleshoot the nvm installation.

  • Close the current VSCodium terminal.
  • Open a fresh terminal from Terminal using New Terminal.
  • Verify that the new terminal can find nvm by running:
command -v nvm

What should I see?

The command should print nvm. That output confirms that the fresh shell loaded the version manager.

nvm is still unavailable?

Close every VSCodium terminal before creating another one. This forces the new shell to load its updated configuration.

If no output appears, help me load nvm in my Linux shell.

The Node.js download can take a short while. The terminal may stay busy while nvm downloads the runtime.

  • Install Node.js 24.21.0 by running:
nvm install 24.21.0

What does this command do?

nvm downloads Node.js 24.21.0 LTS. It prepares that runtime for the current shell.

Node.js download failed?

Check your network connection before retrying the command. Confirm that command -v nvm produced the expected output.

If the download still fails, help me diagnose the nvm Node.js installation.

  • Confirm the upgraded runtime by running the version checks again:
node -v
npm -v

What should I see?

The first command should now report v24.21.0. The second command should print an npm version number.

ⓧ A command is not found

Install nvm 0.40.8 first. You can use it to install Node.js 24.21.0 LTS with npm.

  • Install nvm 0.40.8 by running this command:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash

What does this command do?

The command downloads the official nvm 0.40.8 installation script. Bash runs that script to add nvm to your shell environment.

nvm installation failed?

Confirm that your computer has internet access. Check whether your terminal can run curl.

If the installation still fails, help me troubleshoot the nvm installation.

  • Close the current VSCodium terminal.
  • Open a fresh terminal from Terminal using New Terminal.
  • Verify that the new terminal can find nvm by running:
command -v nvm

What should I see?

The command should print nvm. That output confirms that the fresh shell loaded the version manager.

nvm is still unavailable?

Close every VSCodium terminal before creating another one. This forces the new shell to load its updated configuration.

If no output appears, help me load nvm in my Linux shell.

The Node.js download can take a short while. The terminal may stay busy while nvm downloads the runtime.

  • Install Node.js 24.21.0 by running:
nvm install 24.21.0

What does this command do?

nvm downloads Node.js 24.21.0 LTS. It prepares that runtime for the current shell.

Node.js download failed?

Check your network connection before retrying the command. Confirm that command -v nvm produced the expected output.

If the download still fails, help me diagnose the nvm Node.js installation.

  • Confirm the installed runtime by running the version checks again:
node -v
npm -v

What should I see?

The first command should report v24.21.0. The second command should print an npm version number.

Create and run the project shell

The project shell connects four source files to the pinned dependencies. This gives the next step a clean place to build the first 3D scene.

  • Move to your Desktop before creating the project folder by running:
cd ~/Desktop
mkdir soict-b1-tour
cd soict-b1-tour

What do these commands do?

The first command moves the terminal to your Desktop. The second command creates the soict-b1-tour folder.

The final command moves the terminal into that new folder. Files created from this terminal now belong to the B1 tour.

  • Select File from the VSCodium menu bar.
  • Select Open Folder.
  • Choose the soict-b1-tour folder from your Desktop.
  • Select Open.

You'll see soict-b1-tour at the top of the VSCodium file sidebar. That confirms the editor is working inside the correct folder.

Cannot find the project folder?

Return to the terminal. Confirm that its current path ends with Desktop/soict-b1-tour.

If the folder remains missing, help me locate my soict-b1-tour folder.

The package manifest pins every dependency used by this project. Exact versions keep the tour reproducible across different computers.

  • Select the new-file control beside soict-b1-tour in the VSCodium file sidebar.
  • Enter package.json as the file name.

An empty package.json file opens in the editor.

  • Define the project scripts plus the pinned dependencies by pasting this code into package.json:
{
  "name": "soict-b1-tour",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "three": "0.186.1"
  },
  "devDependencies": {
    "vite": "8.3.4"
  }
}

What does this file do?

  • The scripts map the project commands to Vite's development server plus its production tools.
  • The dependencies pin Three.js to 0.186.1.
  • The development dependencies pin Vite to 8.3.4.
  • Press Ctrl+S to save package.json.
  • Confirm that package.json appears under soict-b1-tour in the file sidebar.

Seeing a JSON warning?

Check every comma plus quotation mark against the reference block. JSON requires double quotation marks around its keys.

If the warning remains, help me compare my package.json with the project manifest.

  • Install the dependencies listed in package.json by running:
npm install

What does this command do?

npm reads package.json before downloading the pinned packages. The local project receives Three.js 0.186.1 plus Vite 8.3.4.

The terminal returns to its prompt after installation. A dependency summary confirms that npm completed its work.

Dependency installation failed?

Confirm that node -v reports a compatible version. Check that the terminal is inside the soict-b1-tour folder.

If npm still fails, help me troubleshoot npm install.

The HTML shell gives the browser a document plus one application container. Vietnamese metadata identifies the page as the B1 tour.

  • Select the new-file control beside soict-b1-tour in the file sidebar.
  • Enter index.html as the file name.

An empty index.html file opens in the editor.

  • Add the browser shell by pasting this code into index.html:
<!doctype html>
<html lang="vi">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="Bản tham quan 3D low-poly quanh Nhà B1 của SoICT."
    />
    <title>Tour 3D quanh Nhà B1 SoICT</title>
  </head>
  <body>
    <main id="app">
    </main>

    <script type="module" src="/main.js"></script>
  </body>
</html>

What does this file do?

  • The Vietnamese language metadata tells the browser which language the document uses.
  • The app element becomes the container for the future 3D canvas.
  • The module script loads main.js when Vite serves the page.
  • Press Ctrl+S to save index.html.
  • Confirm that index.html appears in the file sidebar.

HTML structure looks incomplete?

Check that every opening element has the matching closing element. Confirm that the module source is /main.js.

If the structure still differs, help me compare my index.html with the B1 shell.

The first stylesheet establishes the dark background used behind the future 3D canvas. Its text defaults also prepare the interface for later controls.

  • Select the new-file control beside soict-b1-tour in the file sidebar.
  • Enter style.css as the file name.

An empty style.css file opens in the editor.

  • Add the project color foundation by pasting this code into style.css:
:root {
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  color: #f7fbff;
  background: #081522;
  font-synthesis: none;
}

What does this stylesheet do?

  • The font stack uses common system fonts without downloading an external asset.
  • The foreground color prepares light text for the future interface.
  • The background color creates the dark shell behind the 3D scene.
  • Press Ctrl+S to save style.css.
  • Confirm that style.css appears in the file sidebar.

Seeing a CSS warning?

Check the opening plus closing braces around :root.

If the warning remains, help me compare my style.css with the project foundation.

The JavaScript entry module connects the stylesheet to Vite's module graph. The next step expands this same file with the Three.js scene.

  • Select the new-file control beside soict-b1-tour in the file sidebar.
  • Enter main.js as the file name.

An empty main.js file opens in the editor.

  • Connect the stylesheet by pasting this entry module into main.js:
import './style.css';

What does this module do?

The import tells Vite to include style.css in the browser page. This connection makes the dark project shell visible when the server starts.

  • Press Ctrl+S to save main.js.
  • Confirm that main.js appears beside the other three source files.

Import path shows a warning?

Confirm that main.js plus style.css are in the same folder. Check that the import includes the leading dot plus slash.

If the warning remains, help me fix the stylesheet import.

✔️ Awesome, I've got everything!

Great. Save every file before starting the development server.

ⓧ I'd like to double check the full code

Your package.json should match:

{
  "name": "soict-b1-tour",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "three": "0.186.1"
  },
  "devDependencies": {
    "vite": "8.3.4"
  }
}

Your index.html should match:

<!doctype html>
<html lang="vi">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="Bản tham quan 3D low-poly quanh Nhà B1 của SoICT."
    />
    <title>Tour 3D quanh Nhà B1 SoICT</title>
  </head>
  <body>
    <main id="app">
    </main>

    <script type="module" src="/main.js"></script>
  </body>
</html>

Your style.css should match:

:root {
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  color: #f7fbff;
  background: #081522;
  font-synthesis: none;
}

Your main.js should match:

import './style.css';

Before you start the server, what page color do you expect the imported stylesheet to produce?

The development server keeps this terminal busy while it runs. Press Ctrl+C when you eventually want to stop it.

  • Start the Vite development server by running:
npm run dev

What does this command do?

npm runs the dev script from package.json. Vite starts a local server for the project files.

The terminal prints the local address when the server is ready. It should finish startup without an error.

  • Open a web browser on your Linux computer.
  • Enter http://localhost:5173 in the address bar.

You'll see a dark navy page. The browser tab reads Tour 3D quanh Nhà B1 SoICT.

The project shell does not load?

Keep the development server running while the browser is open. Confirm that the browser address uses port 5173.

If the terminal reports a source-file problem, compare all four files with the full-code tab. Help me diagnose the Vite startup problem.

That's the foundation secured: your pinned Vite project now runs locally without terminal errors. Next, you'll turn the dark shell into a visible 3D model of Nhà B1.

Make Nhà B1 Appear in 3D

The local project shell is running. You now have a reliable base for the first visible part of the Nhà B1 tour.

A visible building proves that the Three.js pipeline works from start to finish.

The Scene holds your 3D objects. The PerspectiveCamera defines the view visitors see.

In this step, get ready to:
  • Connect the Three.js scene to a transparent browser renderer.
  • Configure OrbitControls for rotating, panning, and zooming.
  • Build a ten-floor low-poly model of Nhà B1.
Create the scene, camera, and renderer

The WebGLRenderer converts your scene into pixels inside a browser canvas. A transparent renderer lets the existing page background remain visible behind the model.

  • Return to main.js in the VSCodium window from earlier.
  • Select the existing minimal entry code.
  • Replace it with this scene foundation:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import './style.css';

const app = document.querySelector('#app');

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  300,
);
camera.position.set(28, 24, 34);

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
app.prepend(renderer.domElement);

What does this code do?

  • The imports load the Three.js library.
  • The imports also make OrbitControls available for the next substep.
  • The stylesheet import keeps the project interface connected to style.css.
  • The camera starts at coordinates 28, 24, 34. This position looks down toward the future building.
  • The renderer uses antialiasing to soften object edges.
  • The alpha option preserves the page background behind the canvas.
  • Save main.js.
  • Return to the terminal panel from earlier.

The development server should continue running without a module import error. This confirms that the scene foundation loads inside the existing project.

Seeing an import problem?

  • Confirm that main.js contains the three import paths exactly as shown.
  • Check that index.html still contains main#app.
  • Confirm that the terminal is still inside the soict-b1-tour folder.

Still stuck? Help me debug the Three.js imports in my Vite project.

Add orbit controls and the animation loop

OrbitControls translates mouse or trackpad input into camera movement. Damping gives that movement a smoother finish.

  • Insert the controls setup below the line that adds the renderer canvas to app.
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.minDistance = 12;
controls.maxDistance = 72;
controls.maxPolarAngle = Math.PI / 2.05;
controls.target.set(0, 5, 0);
controls.update();

How are the controls configured?

  • The controls listen for input on the renderer canvas.
  • The damping factor softens camera movement between frames.
  • The distance limits prevent the camera from moving too close or too far away.
  • The polar-angle limit keeps the camera above the ground.
  • The target points toward the middle of the ten-floor building.
  • Save main.js.
  • Refresh the browser tab from earlier.

The project shell should remain available without a runtime error. The controls are now attached to the same camera used by the renderer.

Controls setup causing an error?

  • Check that the controls block appears after the camera declaration.
  • Check that the controls block also appears after the renderer declaration.
  • Confirm that the import path ends with OrbitControls.js.

Need another pair of eyes? Help me fix my OrbitControls setup.

An animation loop redraws the scene every frame. Calling the controls update method inside that loop applies damping continuously.

  • Add the animation loop at the bottom of main.js:
function animate(time) {
  controls.update();
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Why use an animation loop?

  • The controls update applies damped camera movement on every frame.
  • The renderer draws the current scene from the camera viewpoint.
  • The animation-loop registration asks the browser to repeat that work continuously.
  • Save main.js.
  • Refresh the browser tab from earlier.

The page should remain responsive without a rendering error. The scene now has a continuous render path.

Page stopped rendering?

  • Confirm that animate() appears after the controls setup.
  • Check the spelling of controls.update().
  • Check the spelling of renderer.render().

Still seeing a blank or frozen page? Help me debug the animation loop in main.js.

Build the ten-floor B1 model

A mesh combines geometry with a material. Reusing one helper keeps every low-poly box consistent while the building grows.

  • Insert the box helper immediately above function animate(time):
function addBox(width, height, depth, color, x, y, z, data = {}) {
  const geometry = new THREE.BoxGeometry(width, height, depth);
  const material = new THREE.MeshBasicMaterial({ color });
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.set(x, y, z);
  mesh.userData = data;
  scene.add(mesh);
  return mesh;
}

addBox(40, 0.5, 32, 0x5f8d55, 0, -0.25, 0);

What does the box helper do?

  • The helper creates box geometry from the supplied dimensions.
  • The material gives each box a flat color without requiring lights.
  • The mesh combines the geometry with its material.
  • The position values place the finished mesh inside the scene.
  • The first helper call creates a wide green ground slab beneath B1.
  • Save main.js.
  • Refresh the browser tab from earlier.

You should see a wide green slab through the camera. This is the first visible object produced by your scene pipeline.

Ground slab missing?

  • Confirm that the ground call appears after the closing brace of addBox().
  • Check that scene.add(mesh) remains inside the helper.
  • Compare the camera position with the scene setup shown earlier.

Need help finding the missing slab? Help me debug the addBox helper and ground mesh.

Each B1 floor uses the same box dimensions. Alternating colors separate the levels while a dark façade strip marks the front edge.

  • Add the floor-building loop immediately below the ground slab call:
const floorHeight = 1.05;
const floorBase = 0.65;

for (let floor = 1; floor <= 10; floor += 1) {
  const y = floorBase + (floor - 1) * floorHeight;
  const color = floor % 2 === 0 ? 0xc8d6df : 0xe0e8ed;
  addBox(12, 1, 7, color, 0, y, 0);
  addBox(10.2, 0.42, 0.12, 0x24425f, 0, y, 3.56);
}

How does the floor loop work?

  • The floor height determines the vertical gap between levels.
  • The floor base places the first level above the ground slab.
  • The loop calculates a new vertical position for each of the ten levels.
  • The color expression alternates between two pale façade colors.
  • The second box on each level creates the dark front strip.
  • Save main.js.
  • Refresh the browser tab from earlier.

You should now see ten stacked floor boxes above the green slab. Dark horizontal strips should run across the front of the building.

Floors not stacking correctly?

  • Confirm that the loop starts at floor 1.
  • Confirm that the loop ends at floor 10.
  • Check the vertical position calculation for the local y value.

Want help comparing the levels? Help me debug the ten-floor B1 loop.

The final boxes establish the building silhouette. A red roof caps the tower while a canopy with two columns defines the entrance.

  • Add the roof and entrance details immediately below the floor loop:
addBox(12.6, 0.35, 7.6, 0xa72a32, 0, 10.75, 0);
addBox(5.2, 0.3, 2.3, 0xa72a32, 0, 1.7, 4.5);
addBox(0.35, 1.7, 0.35, 0xe7ecef, -2.1, 0.9, 4.5);
addBox(0.35, 1.7, 0.35, 0xe7ecef, 2.1, 0.9, 4.5);

What completes the silhouette?

  • The wide red box forms a shallow roof above the tenth floor.
  • The smaller red box extends outward as the entrance canopy.
  • The two narrow pale boxes support the canopy.
  • These details create a readable entrance without external model assets.
  • Save main.js.
  • Refresh the browser tab from earlier.

You should see a red roof above the tenth floor. The front of B1 should now include a red canopy supported by two pale columns.

Roof or entrance missing?

  • Confirm that all four detail calls appear below the floor loop.
  • Check that every detail call uses addBox().
  • Compare each position value with the code shown above.

Still missing a detail? Help me compare the roof, canopy, and column calls.

✔️ Awesome, I've got everything!

Great. Save main.js once more before testing the complete model.

ⓧ I'd like to double check the full code

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import './style.css';

const app = document.querySelector('#app');

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  300,
);
camera.position.set(28, 24, 34);

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
app.prepend(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.minDistance = 12;
controls.maxDistance = 72;
controls.maxPolarAngle = Math.PI / 2.05;
controls.target.set(0, 5, 0);
controls.update();

function addBox(width, height, depth, color, x, y, z, data = {}) {
  const geometry = new THREE.BoxGeometry(width, height, depth);
  const material = new THREE.MeshBasicMaterial({ color });
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.set(x, y, z);
  mesh.userData = data;
  scene.add(mesh);
  return mesh;
}

addBox(40, 0.5, 32, 0x5f8d55, 0, -0.25, 0);

const floorHeight = 1.05;
const floorBase = 0.65;

for (let floor = 1; floor <= 10; floor += 1) {
  const y = floorBase + (floor - 1) * floorHeight;
  const color = floor % 2 === 0 ? 0xc8d6df : 0xe0e8ed;
  addBox(12, 1, 7, color, 0, y, 0);
  addBox(10.2, 0.42, 0.12, 0x24425f, 0, y, 3.56);
}

addBox(12.6, 0.35, 7.6, 0xa72a32, 0, 10.75, 0);
addBox(5.2, 0.3, 2.3, 0xa72a32, 0, 1.7, 4.5);
addBox(0.35, 1.7, 0.35, 0xe7ecef, -2.1, 0.9, 4.5);
addBox(0.35, 1.7, 0.35, 0xe7ecef, 2.1, 0.9, 4.5);

function animate(time) {
  controls.update();
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

How to use this reference

Compare this reference with your complete main.js file. Pay close attention to declaration order because each section uses values created above it.

Before you check the finished scene, predict how the camera should respond when you drag or scroll over the model.

  • Return to the terminal panel from earlier.
  • Restart the development server if it has stopped by running:
npm run dev

What does this command do?

This command starts the Vite development server defined in package.json. It serves the current project through the local browser address.

Development server not starting?

  • Confirm that the terminal is inside the soict-b1-tour folder.
  • Check that the previous terminal output does not show a JavaScript syntax problem in main.js.
  • Compare the complete file with the reference tab above.

Need help reading the terminal output? Help me troubleshoot why the B1 tour development server does not start.

  • Switch back to the browser tab at http://localhost:5173.
  • Refresh the page.
  • Drag across the canvas with the primary mouse button.
  • Drag across the canvas with the secondary mouse button.
  • Scroll over the canvas.

You should see a ten-floor low-poly B1 model on a green slab. The first drag rotates the view. The second drag pans the view. Scrolling changes the camera distance.

That is the first real 3D payoff. Your building is visible from multiple angles through a working browser camera.

Nhà B1 now has a working 3D form plus camera navigation. Next, you will build the surrounding area and add two visible points of interest.

Build the Area Around B1

Your ten-floor B1 model now responds to orbit, pan, and zoom controls. That is a strong milestone for your browser-based tour.

A lone building gives visitors no sense of the area around it. Conceptual roads, paths, greenery, and nearby structures give B1 a readable setting.

The tour also needs places that visitors can inspect. You'll place two verified point-of-interest markers before testing their current behavior.

In this step, get ready to:
  • Build a conceptual road, walkway, and lawn around Nhà B1.
  • Populate the surroundings with trees and unnamed context buildings.
  • Place the verified SoICT office and BKAI markers before testing their behavior.
Create the ground layout

The existing ground slab acts as the lawn beneath your model. Three slim boxes add visual routes around B1 without claiming geographic accuracy.

  • Switch back to main.js in VSCodium.
  • Find the existing ground slab created with addBox().
  • Place the following three boxes directly beneath the ground slab by pasting this code:
addBox(8, 0.08, 32, 0x30373d, -12, 0.04, 0);
addBox(4, 0.09, 25, 0xc7b997, 10, 0.05, 0);
addBox(14, 0.1, 10, 0xb7a98a, 0, 0.06, 10);

What Does This Code Do?

  • The dark box creates a road along one side of B1.
  • The narrow beige box creates a walkway on the opposite side.
  • The wider beige box creates a paved area near the entrance.
  • Each surface sits slightly above the ground slab to remain visible.
  • Save main.js.
  • Return to the browser tab from earlier.
  • Orbit around B1 with your mouse or trackpad.

You'll see a dark road beside B1. You'll also see two pale paved areas crossing the green lawn.

Don't See the Ground Layout?

  • Confirm that all three new addBox() calls sit below the existing ground slab line.
  • Check each hexadecimal color for a missing digit.
  • Save main.js again after correcting the snippet.

Still missing a surface? Help me compare my ground-layout code with the expected Three.js boxes.

Populate the surroundings

Repeated scenery benefits from a helper function. The same trunk and crown geometry can produce several trees at different coordinates.

  • Find the two existing column boxes beneath the entrance canopy in main.js.
  • Add the tree helper and nearby structures directly beneath those columns by pasting this code:
function addTree(x, z) {
  addBox(0.35, 1.8, 0.35, 0x76533a, x, 0.9, z);
  addBox(1.6, 1.6, 1.6, 0x2f6f49, x, 2.2, z);
}

[
  [-17, -10],
  [-17, -4],
  [-17, 5],
  [-17, 11],
  [14, -10],
  [14, -4],
  [14, 5],
  [14, 11],
  [8, -11],
  [3, -11],
].forEach(([x, z]) => addTree(x, z));

addBox(7, 2.8, 5, 0x617788, -16, 1.4, -11);
addBox(8, 3.8, 5, 0x718698, 15, 1.9, 10);

How Is the Scene Populated?

  • The addTree() helper accepts one horizontal coordinate and one depth coordinate.
  • The first box inside the helper creates a brown trunk.
  • The second box creates a green canopy above that trunk.
  • The coordinate list calls the helper ten times around the scene.
  • The final two boxes suggest nearby buildings without assigning them factual names.
  • Save main.js.
  • Return to the running tour in your browser.
  • Drag around the scene to inspect the new surroundings.

You'll see ten blocky trees around the paths. You'll also see one context building on each side of B1.

Keep the Model Conceptual

The surrounding layout represents visual context for this MVP. Its proportions and coordinates are not surveyed campus data.

Missing Trees or Buildings?

  • Check that the closing brace for addTree() appears before the coordinate list.
  • Confirm that every coordinate pair contains two numbers.
  • Compare the final two addBox() calls with the snippet above.

Still seeing an incomplete scene? Help me troubleshoot my addTree helper and context-building boxes.

Place the verified markers

Markers give visitors visible points to investigate. Their model coordinates are visual placeholders for this stylized scene.

  • Find the two unnamed context-building boxes you just added near the bottom of main.js.
  • Place the marker collection directly beneath those boxes by pasting this code:
const interactiveObjects = [];
const officeMarker = addBox(1.15, 1.15, 1.15, 0xffca67, 7.4, 4.85, 0, {
  poi: 'office',
});
const bkaiMarker = addBox(1.15, 1.15, 1.15, 0x66d9c8, 7.4, 10.1, 0, {
  poi: 'bkai',
});
interactiveObjects.push(officeMarker, bkaiMarker);

How Are the Markers Identified?

  • The gold officeMarker represents the verified SoICT office point.
  • The teal bkaiMarker represents the verified BKAI point.
  • Each marker stores a short identifier in userData.poi.
  • The interactiveObjects array keeps references to both marker meshes.
  • Save main.js.
  • Return to the running tour in your browser.
  • Orbit toward the right side of B1.

You'll see a gold marker near the middle floors. You'll see a teal marker near the top floor.

Can't Find Both Markers?

  • Orbit toward the right side of B1 to reveal the marker positions.
  • Check that both marker calls use an x-coordinate of 7.4.
  • Confirm that the office marker uses gold and the BKAI marker uses teal.

Still missing a marker? Help me compare my marker positions and colors with the target code.

Use these tabs to compare your cumulative main.js before testing the markers.

✔️ Awesome, I've got everything!

Your saved main.js now includes the B1 model, conceptual surroundings, and two visible markers.

ⓧ I'd like to double check the full code

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import './style.css';

const app = document.querySelector('#app');

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  300,
);
camera.position.set(28, 24, 34);

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
app.prepend(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.minDistance = 12;
controls.maxDistance = 72;
controls.maxPolarAngle = Math.PI / 2.05;
controls.target.set(0, 5, 0);
controls.update();

function addBox(width, height, depth, color, x, y, z, data = {}) {
  const geometry = new THREE.BoxGeometry(width, height, depth);
  const material = new THREE.MeshBasicMaterial({ color });
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.set(x, y, z);
  mesh.userData = data;
  scene.add(mesh);
  return mesh;
}

addBox(40, 0.5, 32, 0x5f8d55, 0, -0.25, 0);
addBox(8, 0.08, 32, 0x30373d, -12, 0.04, 0);
addBox(4, 0.09, 25, 0xc7b997, 10, 0.05, 0);
addBox(14, 0.1, 10, 0xb7a98a, 0, 0.06, 10);

const floorHeight = 1.05;
const floorBase = 0.65;

for (let floor = 1; floor <= 10; floor += 1) {
  const y = floorBase + (floor - 1) * floorHeight;
  const color = floor % 2 === 0 ? 0xc8d6df : 0xe0e8ed;
  addBox(12, 1, 7, color, 0, y, 0);
  addBox(10.2, 0.42, 0.12, 0x24425f, 0, y, 3.56);
}

addBox(12.6, 0.35, 7.6, 0xa72a32, 0, 10.75, 0);
addBox(5.2, 0.3, 2.3, 0xa72a32, 0, 1.7, 4.5);
addBox(0.35, 1.7, 0.35, 0xe7ecef, -2.1, 0.9, 4.5);
addBox(0.35, 1.7, 0.35, 0xe7ecef, 2.1, 0.9, 4.5);

function addTree(x, z) {
  addBox(0.35, 1.8, 0.35, 0x76533a, x, 0.9, z);
  addBox(1.6, 1.6, 1.6, 0x2f6f49, x, 2.2, z);
}

[
  [-17, -10],
  [-17, -4],
  [-17, 5],
  [-17, 11],
  [14, -10],
  [14, -4],
  [14, 5],
  [14, 11],
  [8, -11],
  [3, -11],
].forEach(([x, z]) => addTree(x, z));

addBox(7, 2.8, 5, 0x617788, -16, 1.4, -11);
addBox(8, 3.8, 5, 0x718698, 15, 1.9, 10);

const interactiveObjects = [];
const officeMarker = addBox(1.15, 1.15, 1.15, 0xffca67, 7.4, 4.85, 0, {
  poi: 'office',
});
const bkaiMarker = addBox(1.15, 1.15, 1.15, 0x66d9c8, 7.4, 10.1, 0, {
  poi: 'bkai',
});
interactiveObjects.push(officeMarker, bkaiMarker);

function animate() {
  controls.update();
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);
  • Narrow the browser window to test the compact view.

You should still see the B1 scene and both colored markers inside the browser.

Before you click either marker, what do you expect the camera or page to do?

  • Click the gold office marker.
  • Click the teal BKAI marker.

The scene remains visible after both clicks. No information opens.

The camera also stays under your manual control. This silent result is the intended interaction gap.

Why Are the Clicks Silent?

Your code creates two visible meshes with identifying data. It does not yet connect a screen click to either 3D object.

That screen-to-object match is called 3D picking. The missing match explains why visuals alone cannot power an interactive tour.

Your B1 model now sits inside a readable neighborhood with two verified destinations. Next, you'll connect those markers to information and camera movement.

Turn Markers into an Interactive Tour

Your B1 scene now has two visible markers. You also confirmed that clicking them produces no response.

A raycaster closes that interaction gap in Three.js. It converts a screen click into a selected 3D object. You can then connect that object to verified information plus a camera destination.

In this step, get ready to:
  • Build the responsive tour interface plus information panel.
  • Connect each point of interest to verified content plus a camera view.
  • Use raycasting to select markers plus move the camera smoothly.
Build the tour interface

The canvas handles the 3D scene. An HTML overlay gives visitors readable controls plus a place for verified location details.

  • In the VSCodium Explorer sidebar, select index.html.
  • Replace the existing main#app element with this interface markup:
    <main id="app">
      <header class="hud">
        <div class="intro-card">
          <p class="eyebrow">Đại học Bách khoa Hà Nội</p>
          <h1>Khám phá Nhà B1</h1>
          <p>Kéo để xoay, cuộn để thu phóng, nhấn marker để xem thông tin.</p>
        </div>

        <nav class="toolbar" aria-label="Điều khiển tham quan">
          <button id="tour-button" type="button">Tham quan B1</button>
          <button type="button" data-poi="office">Văn phòng 505</button>
          <button type="button" data-poi="bkai">BKAI tầng 10</button>
          <button id="reset-button" type="button" class="secondary">Đặt lại</button>
        </nav>
      </header>

      <aside id="info-panel" class="info-panel hidden" aria-live="polite">
        <button id="close-info" class="close-button" type="button" aria-label="Đóng">
          ×
        </button>
        <p class="eyebrow">Điểm tham quan</p>
        <h2 id="info-title"></h2>
        <p id="info-body"></p>
      </aside>
    </main>

What Does This Markup Do?

  • The hud header places the introduction plus tour controls above the 3D canvas.
  • The two data-poi values match the existing office plus bkai marker identifiers.
  • The info-panel starts with the hidden class. JavaScript removes that class after a visitor selects a marker.
  • Save index.html.
  • Switch back to the browser from earlier.

You should see the introduction plus four tour buttons above the B1 scene. The information panel stays hidden until a point of interest is selected.

Interface Missing From the Scene?

Confirm that the replacement still sits inside the existing body element. Check that the module script remains below main.

If the browser shows malformed markup, help me check the new interface structure.

✔️ Awesome, I've got everything!

Your interface markup is saved. Continue with the responsive styles.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="vi">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="Bản tham quan 3D low-poly quanh Nhà B1 của SoICT."
    />
    <title>Tour 3D quanh Nhà B1 SoICT</title>
  </head>
  <body>
    <main id="app">
      <header class="hud">
        <div class="intro-card">
          <p class="eyebrow">Đại học Bách khoa Hà Nội</p>
          <h1>Khám phá Nhà B1</h1>
          <p>Kéo để xoay, cuộn để thu phóng, nhấn marker để xem thông tin.</p>
        </div>

        <nav class="toolbar" aria-label="Điều khiển tham quan">
          <button id="tour-button" type="button">Tham quan B1</button>
          <button type="button" data-poi="office">Văn phòng 505</button>
          <button type="button" data-poi="bkai">BKAI tầng 10</button>
          <button id="reset-button" type="button" class="secondary">Đặt lại</button>
        </nav>
      </header>

      <aside id="info-panel" class="info-panel hidden" aria-live="polite">
        <button id="close-info" class="close-button" type="button" aria-label="Đóng">
          ×
        </button>
        <p class="eyebrow">Điểm tham quan</p>
        <h2 id="info-title"></h2>
        <p id="info-body"></p>
      </aside>
    </main>

    <script type="module" src="/main.js"></script>
  </body>
</html>

The interface needs CSS that preserves the full-screen canvas. The same stylesheet also keeps the controls usable on narrow screens.

  • In the VSCodium Explorer sidebar, select style.css.
  • Replace its existing contents with this full-page foundation:
:root {
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  color: #f7fbff;
  background: #081522;
  font-synthesis: none;
}

* {
  box-sizing: border-box;
}

html,
body,
#app {
  width: 100%;
  min-height: 100%;
  margin: 0;
}

body {
  min-height: 100vh;
  overflow: hidden;
}

What Does This Foundation Do?

  • The root rules establish the typeface plus high-contrast page colours.
  • The shared sizing rules let html, body, plus #app fill the viewport.
  • The hidden body overflow prevents scrollbars from competing with 3D navigation.
  • Save style.css.
  • Switch back to the browser from earlier.

You should see a dark full-page foundation without an outer scrollbar.

Still Seeing Page Margins?

Check that the html, body, plus #app selector keeps its commas. Confirm that margin is set to 0.

If the spacing remains, help me find which page rule is adding the margin.

  • Append the app background plus fixed canvas layout below the body block:
#app {
  min-height: 100vh;
  background:
    radial-gradient(circle at 50% 20%, rgba(53, 128, 153, 0.48), transparent 42%),
    linear-gradient(180deg, #132c40 0%, #0a1825 70%, #07111b 100%);
}

canvas {
  position: fixed;
  inset: 0;
  display: block;
  width: 100vw;
  height: 100vh;
  touch-action: none;
}

.hud {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 2;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
  padding: 1rem;
  pointer-events: none;
}

How Does the Overlay Work?

  • The layered background stays behind the transparent renderer.
  • The fixed canvas fills the available viewport.
  • The fixed hud sits above the canvas. Its default pointer behaviour leaves the 3D controls reachable.
  • Save style.css.
  • Switch back to the browser from earlier.

You should see the B1 model framed by a blue gradient. The interface stays pinned across the top of the viewport.

Canvas Covering the Interface?

Confirm that .hud has a z-index of 2. Check that the canvas does not have a higher stacking value.

If the order is still wrong, help me debug the canvas and HUD stacking order.

  • Append the shared glass-panel styles plus introduction typography below the .hud block:
.intro-card,
.toolbar,
.info-panel {
  border: 1px solid rgba(255, 255, 255, 0.16);
  background: rgba(7, 17, 27, 0.78);
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.26);
  backdrop-filter: blur(14px);
}

.intro-card {
  max-width: 26rem;
  padding: 1rem 1.1rem;
  border-radius: 1rem;
}

.intro-card h1,
.info-panel h2 {
  margin: 0.2rem 0 0.45rem;
}

.intro-card p,
.info-panel p {
  margin: 0;
  line-height: 1.55;
  color: #d6e6f2;
}

Why Share These Panel Styles?

The introduction card, toolbar, plus information panel use one visual system. The translucent background keeps the scene visible while preserving readable text.

  • Save style.css.
  • Switch back to the browser from earlier.

You should see the introduction inside a rounded translucent card. The B1 scene remains visible behind it.

Card Background Missing?

Check that .intro-card appears in the grouped selector. Confirm that the background colour retains its alpha value.

If the card stays unstyled, help me trace why the grouped panel selector is not applying.

  • Append the eyebrow, toolbar, plus base button styles below the introduction text rules:
.eyebrow {
  color: #ffca67 !important;
  font-size: 0.74rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.toolbar {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  max-width: 35rem;
  padding: 0.55rem;
  border-radius: 0.9rem;
  pointer-events: auto;
}

button {
  border: 0;
  border-radius: 0.65rem;
  padding: 0.72rem 0.9rem;
  color: #07111b;
  background: #ffca67;
  font: inherit;
  font-weight: 750;
  cursor: pointer;
}

How Do the Controls Stay Clickable?

The toolbar restores pointer events for its controls. The surrounding HUD still lets pointer input reach the canvas.

The gold buttons echo the existing SoICT office marker. That shared colour helps connect the interface with the scene.

  • Save style.css.
  • Switch back to the browser from earlier.

You should see the four controls grouped inside a translucent toolbar. Each button has a gold background.

Buttons Ignoring Pointer Input?

Confirm that .toolbar sets pointer-events to auto. The parent HUD intentionally disables its own pointer events.

If the buttons remain unreachable, help me inspect pointer events in the HUD.

  • Append the button states plus information-panel layout below the base button block:
button + button {
  margin-left: 0.45rem;
}

button:hover,
button:focus-visible {
  background: #ffe0a3;
  outline: 2px solid #ffffff;
  outline-offset: 2px;
}

button.secondary {
  color: #eef7ff;
  background: #23445b;
}

.info-panel {
  position: fixed;
  right: 1rem;
  bottom: 1rem;
  z-index: 3;
  width: min(24rem, calc(100vw - 2rem));
  padding: 1.2rem;
  border-radius: 1rem;
}

What Changes When Information Opens?

The information panel occupies the lower-right corner. Its responsive width keeps one rem of space on each side of a narrow viewport.

The secondary button styling separates the reset action from the destination controls.

  • Save style.css.
  • Switch back to the browser from earlier.

You should see a blue Đặt lại button. The other controls retain their gold styling.

Reset Button Still Gold?

Confirm that the markup uses class="secondary" on the reset button. Check that the button.secondary rule comes after the base button rule.

If the colour remains unchanged, help me find the CSS rule overriding the secondary button.

  • Append the close control plus hidden-state rules below the information-panel block:
.close-button {
  position: absolute;
  top: 0.65rem;
  right: 0.65rem;
  width: 2rem;
  height: 2rem;
  padding: 0;
  color: #ffffff;
  background: transparent;
  font-size: 1.4rem;
}

.hidden {
  display: none;
}

How Is the Panel Hidden?

The hidden class removes the information panel from the layout. The selection code removes that class when it has content to show.

  • Save style.css.
  • Switch back to the browser from earlier.

You should still see the B1 scene without an empty information panel in the lower-right corner.

Empty Panel Already Visible?

Confirm that #info-panel includes the hidden class in index.html. Check that the class selector begins with a period in style.css.

If the panel remains visible, help me debug the hidden class.

  • Append the narrow-screen layout below the .hidden block:
@media (max-width: 760px) {
  .hud {
    flex-direction: column;
  }

  .intro-card {
    max-width: min(100%, 25rem);
  }

  .toolbar {
    justify-content: flex-start;
    max-width: 100%;
  }

  button {
    margin: 0.2rem !important;
    padding: 0.62rem 0.72rem;
    font-size: 0.86rem;
  }
}

What Happens Below 760 Pixels?

The HUD switches from a row to a column. The toolbar aligns left. Smaller button spacing leaves more room for the 3D scene.

  • Save style.css.
  • Narrow the browser window below 760px.

You should see the toolbar move below the introduction card. Its buttons wrap without extending beyond the viewport.

Toolbar Overflowing on a Narrow Screen?

Confirm that the media query closes after the button rule. Check that the toolbar keeps max-width at 100% inside that query.

If horizontal overflow remains, help me identify which responsive rule is too wide.

✔️ Awesome, I've got everything!

Your responsive HUD plus hidden information panel are ready.

ⓧ I'd like to double check the full code

:root {
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  color: #f7fbff;
  background: #081522;
  font-synthesis: none;
}

* {
  box-sizing: border-box;
}

html,
body,
#app {
  width: 100%;
  min-height: 100%;
  margin: 0;
}

body {
  min-height: 100vh;
  overflow: hidden;
}

#app {
  min-height: 100vh;
  background:
    radial-gradient(circle at 50% 20%, rgba(53, 128, 153, 0.48), transparent 42%),
    linear-gradient(180deg, #132c40 0%, #0a1825 70%, #07111b 100%);
}

canvas {
  position: fixed;
  inset: 0;
  display: block;
  width: 100vw;
  height: 100vh;
  touch-action: none;
}

.hud {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 2;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
  padding: 1rem;
  pointer-events: none;
}

.intro-card,
.toolbar,
.info-panel {
  border: 1px solid rgba(255, 255, 255, 0.16);
  background: rgba(7, 17, 27, 0.78);
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.26);
  backdrop-filter: blur(14px);
}

.intro-card {
  max-width: 26rem;
  padding: 1rem 1.1rem;
  border-radius: 1rem;
}

.intro-card h1,
.info-panel h2 {
  margin: 0.2rem 0 0.45rem;
}

.intro-card p,
.info-panel p {
  margin: 0;
  line-height: 1.55;
  color: #d6e6f2;
}

.eyebrow {
  color: #ffca67 !important;
  font-size: 0.74rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.toolbar {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  max-width: 35rem;
  padding: 0.55rem;
  border-radius: 0.9rem;
  pointer-events: auto;
}

button {
  border: 0;
  border-radius: 0.65rem;
  padding: 0.72rem 0.9rem;
  color: #07111b;
  background: #ffca67;
  font: inherit;
  font-weight: 750;
  cursor: pointer;
}

button + button {
  margin-left: 0.45rem;
}

button:hover,
button:focus-visible {
  background: #ffe0a3;
  outline: 2px solid #ffffff;
  outline-offset: 2px;
}

button.secondary {
  color: #eef7ff;
  background: #23445b;
}

.info-panel {
  position: fixed;
  right: 1rem;
  bottom: 1rem;
  z-index: 3;
  width: min(24rem, calc(100vw - 2rem));
  padding: 1.2rem;
  border-radius: 1rem;
}

.close-button {
  position: absolute;
  top: 0.65rem;
  right: 0.65rem;
  width: 2rem;
  height: 2rem;
  padding: 0;
  color: #ffffff;
  background: transparent;
  font-size: 1.4rem;
}

.hidden {
  display: none;
}

@media (max-width: 760px) {
  .hud {
    flex-direction: column;
  }

  .intro-card {
    max-width: min(100%, 25rem);
  }

  .toolbar {
    justify-content: flex-start;
    max-width: 100%;
  }

  button {
    margin: 0.2rem !important;
    padding: 0.62rem 0.72rem;
    font-size: 0.86rem;
  }
}
Connect points of interest to camera views

Each marker needs content plus a destination. The content object keeps factual text separate from scene geometry. The camera-view object gives every destination a position plus an OrbitControls target.

  • In the VSCodium Explorer sidebar, select main.js.
  • Find const app = document.querySelector('#app');.
  • Add these information-panel references directly below it:
const infoPanel = document.querySelector('#info-panel');
const infoTitle = document.querySelector('#info-title');
const infoBody = document.querySelector('#info-body');

What Do These References Hold?

These constants point to the panel container plus its two empty text elements. Later functions can update the existing HTML without rebuilding the panel.

  • Save main.js.
  • Switch back to the browser from earlier.

You should still see the complete scene plus HUD. The browser should remain free of a JavaScript error.

Seeing a Missing Element Error?

Compare the selector IDs with #info-panel, #info-title, plus #info-body in index.html.

If one reference is still missing, help me match my JavaScript selectors to the HTML IDs.

  • Find interactiveObjects.push(officeMarker, bkaiMarker);.
  • Add the verified point-of-interest content directly below it:
const poiContent = {
  b1: {
    title: 'Nhà B1 và SoICT',
    body:
      'Mô hình low-poly minh họa khu vực quanh Nhà B1. Nguồn chính thức của SoICT công bố Văn phòng Trường CNTT&TT tại Phòng 505, Nhà B1.',
  },
  office: {
    title: 'Văn phòng Trường CNTT&TT',
    body: 'Phòng 505, Nhà B1, Đại học Bách khoa Hà Nội.',
  },
  bkai: {
    title: 'Trung tâm Nghiên cứu Quốc tế về Trí tuệ nhân tạo',
    body: 'Địa chỉ công bố: Tầng 10, Nhà B1, Đại học Bách khoa Hà Nội.',
  },
};

Why Keep Content in One Object?

  • The office key matches the gold marker's existing userData.poi value.
  • The bkai key matches the teal marker's existing identifier.
  • The b1 entry prepares a general overview for the guided tour in the next step.
  • Add the camera destinations directly below poiContent.
const cameraViews = {
  overview: {
    position: new THREE.Vector3(28, 24, 34),
    target: new THREE.Vector3(0, 5, 0),
  },
  b1: {
    position: new THREE.Vector3(20, 12, 22),
    target: new THREE.Vector3(0, 5.5, 0),
  },
  office: {
    position: new THREE.Vector3(18, 7, 14),
    target: new THREE.Vector3(5.6, 4.85, 0),
  },
  bkai: {
    position: new THREE.Vector3(18, 14, 14),
    target: new THREE.Vector3(5.6, 10.1, 0),
  },
};

How Are the Views Defined?

Each view stores where the camera ends plus where it looks. The office plus BKAI targets align with their existing visual marker heights.

These coordinates belong to the stylized scene. They do not represent surveyed campus positions.

  • Save main.js.
  • Switch back to the browser from earlier.

You should still see the B1 scene plus both markers. The development server should keep running without a JavaScript error.

Scene Disappeared After Adding Views?

Check every closing brace plus comma in poiContent and cameraViews. Confirm that every camera coordinate uses new THREE.Vector3.

If the scene remains blank, help me find the syntax error in my point-of-interest data.

A smooth camera move needs to remember its starting state plus destination. The animation loop can then interpolate between those values over time.

  • Add the transition state plus camera movement function directly below cameraViews.
let transition = null;
let rotationTimer = null;

function flyTo(endPosition, endTarget, onComplete = null) {
  transition = {
    startPosition: camera.position.clone(),
    endPosition: endPosition.clone(),
    startTarget: controls.target.clone(),
    endTarget: endTarget.clone(),
    startTime: performance.now(),
    duration: 1200,
    onComplete,
  };
  controls.enabled = false;
}

What Does flyTo() Store?

  • The cloned camera position plus control target capture the current view.
  • The supplied destination values define the selected point of interest.
  • The 1200 millisecond duration gives the animation loop a fixed interpolation window.
  • Disabling the controls prevents manual input from fighting the transition.
  • Add the guided-rotation cleanup function below flyTo().
function stopGuidedRotation() {
  window.clearTimeout(rotationTimer);
  rotationTimer = null;
  controls.autoRotate = false;
}

Why Stop Rotation Before Focusing?

A selected marker should produce a predictable camera destination. This helper clears any scheduled orbit before the new focus transition begins.

  • Save main.js.
  • Switch back to the browser from earlier.

You should still be able to orbit plus zoom around B1. The browser should remain free of a JavaScript error.

Orbit Controls Stopped Working?

Confirm that flyTo() has not been called during page load. Check that controls.enabled = false remains inside that function.

If the controls are disabled immediately, help me find where flyTo is being called too early.

  • Add the point-of-interest focus function below stopGuidedRotation().
function focusPoi(id, onComplete = null) {
  stopGuidedRotation();
  const view = cameraViews[id];
  flyTo(view.position, view.target, onComplete);
}

What Does focusPoi() Coordinate?

The function uses one identifier to retrieve a complete camera destination. It also stops guided movement before starting the new transition.

  • Add the information-panel function below focusPoi().
function showPoi(id) {
  const content = poiContent[id];
  infoTitle.textContent = content.title;
  infoBody.textContent = content.body;
  infoPanel.classList.remove('hidden');
}

What Does showPoi() Change?

The function loads the matching title plus body into the existing panel. Removing the hidden class makes the completed panel visible.

  • Save main.js.
  • Switch back to the browser from earlier.

You should still see the working scene plus HUD. Both markers should remain visible in their original positions.

Page Blank After Adding the Helpers?

Check that focusPoi() reads from cameraViews. Confirm that showPoi() reads from poiContent.

If the page remains blank, help me compare the helper identifiers with their data objects.

The current animation loop renders the scene continuously. It now needs to apply the stored transition before each frame is drawn.

  • Find the existing animate function near the bottom of main.js.
  • Replace that function with this transition-aware version:
function animate(time) {
  if (transition) {
    const progress = Math.min((time - transition.startTime) / transition.duration, 1);
    const eased = progress * progress * (3 - 2 * progress);

    camera.position.lerpVectors(
      transition.startPosition,
      transition.endPosition,
      eased,
    );
    controls.target.lerpVectors(
      transition.startTarget,
      transition.endTarget,
      eased,
    );

    if (progress === 1) {
      const onComplete = transition.onComplete;
      transition = null;
      controls.enabled = true;
      if (onComplete) onComplete();
    }
  }

  controls.update();
  renderer.render(scene, camera);
}

How Does the Transition Become Smooth?

  • The frame time becomes a progress value from 0 to 1.
  • The easing formula softens the start plus end of the motion.
  • The camera position plus control target interpolate together. The selected object stays centred throughout the movement.
  • The controls become available again after the transition finishes.
  • Save main.js.
  • Switch back to the browser from earlier.

You should see the scene continue animating normally. Dragging plus scrolling should still control the view.

Scene Frozen After Updating animate()?

Confirm that renderer.setAnimationLoop(animate); remains below the function. Check that controls.update() plus renderer.render(scene, camera) remain inside it.

If the scene stays frozen, help me debug the transition-aware animation loop.

Pick markers with raycasting

A browser click starts as a pixel position. Three.js expects normalized device coordinates between -1 plus 1 before it can cast a ray through the camera.

  • Find showPoi() in main.js.
  • Add the raycaster plus pointer state directly below it:
const raycaster = new THREE.Raycaster(
  new THREE.Vector3(),
  new THREE.Vector3(0, 0, -1),
);
const pointer = new THREE.Vector2();

What Do These Objects Represent?

The raycaster tests a line projected through the camera. The two-dimensional pointer stores the normalized horizontal plus vertical click position.

  • Add the canvas click handler directly below the pointer state:
renderer.domElement.addEventListener('click', (event) => {
  const bounds = renderer.domElement.getBoundingClientRect();
  pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
  pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;

  raycaster.setFromCamera(pointer, camera);
  const intersections = raycaster.intersectObjects(interactiveObjects, false);

  if (intersections.length > 0) {
    const id = intersections[0].object.userData.poi;
    showPoi(id);
    focusPoi(id);
  }
});

How Does Marker Selection Work?

  • The canvas bounds convert the browser click into a position relative to the renderer.
  • The horizontal plus vertical formulas map that position into normalized device coordinates.
  • The raycaster tests only interactiveObjects. Roads, trees, plus buildings cannot trigger the panel.
  • The closest hit provides the existing userData.poi identifier. The same identifier drives the information plus camera functions.

Before you test the interaction, which location do you expect the gold marker to open?

  • Save main.js.
  • Switch back to the browser from earlier.
  • Click the gold marker beside B1.

You should see the information panel open with Văn phòng Trường CNTT&TT plus Phòng 505, Nhà B1, Đại học Bách khoa Hà Nội. The camera should move smoothly toward the office view.

  • Click the teal marker near the top of B1.

You should see the panel update to Trung tâm Nghiên cứu Quốc tế về Trí tuệ nhân tạo plus its published location at Tầng 10. The camera should move upward toward the BKAI view.

That interaction gap is closed. Your visible markers now act as entrances to verified information plus focused 3D views.

Marker Click Does Nothing?

  • Confirm that the listener is attached to renderer.domElement.
  • Check that the raycaster tests interactiveObjects.
  • Compare each marker's userData.poi value with the keys in poiContent plus cameraViews.

If neither marker responds, help me debug the normalized pointer coordinates and raycaster intersections.

✔️ Awesome, I've got everything!

Both 3D markers now open verified location details plus focus the camera.

ⓧ I'd like to double check the full code

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import './style.css';

const app = document.querySelector('#app');
const infoPanel = document.querySelector('#info-panel');
const infoTitle = document.querySelector('#info-title');
const infoBody = document.querySelector('#info-body');

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  300,
);
camera.position.set(28, 24, 34);

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
app.prepend(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.minDistance = 12;
controls.maxDistance = 72;
controls.maxPolarAngle = Math.PI / 2.05;
controls.target.set(0, 5, 0);
controls.update();

function addBox(width, height, depth, color, x, y, z, data = {}) {
  const geometry = new THREE.BoxGeometry(width, height, depth);
  const material = new THREE.MeshBasicMaterial({ color });
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.set(x, y, z);
  mesh.userData = data;
  scene.add(mesh);
  return mesh;
}

addBox(40, 0.5, 32, 0x5f8d55, 0, -0.25, 0);
addBox(8, 0.08, 32, 0x30373d, -12, 0.04, 0);
addBox(4, 0.09, 25, 0xc7b997, 10, 0.05, 0);
addBox(14, 0.1, 10, 0xb7a98a, 0, 0.06, 10);

const floorHeight = 1.05;
const floorBase = 0.65;

for (let floor = 1; floor <= 10; floor += 1) {
  const y = floorBase + (floor - 1) * floorHeight;
  const color = floor % 2 === 0 ? 0xc8d6df : 0xe0e8ed;
  addBox(12, 1, 7, color, 0, y, 0);
  addBox(10.2, 0.42, 0.12, 0x24425f, 0, y, 3.56);
}

addBox(12.6, 0.35, 7.6, 0xa72a32, 0, 10.75, 0);
addBox(5.2, 0.3, 2.3, 0xa72a32, 0, 1.7, 4.5);
addBox(0.35, 1.7, 0.35, 0xe7ecef, -2.1, 0.9, 4.5);
addBox(0.35, 1.7, 0.35, 0xe7ecef, 2.1, 0.9, 4.5);

function addTree(x, z) {
  addBox(0.35, 1.8, 0.35, 0x76533a, x, 0.9, z);
  addBox(1.6, 1.6, 1.6, 0x2f6f49, x, 2.2, z);
}

[
  [-17, -10],
  [-17, -4],
  [-17, 5],
  [-17, 11],
  [14, -10],
  [14, -4],
  [14, 5],
  [14, 11],
  [8, -11],
  [3, -11],
].forEach(([x, z]) => addTree(x, z));

addBox(7, 2.8, 5, 0x617788, -16, 1.4, -11);
addBox(8, 3.8, 5, 0x718698, 15, 1.9, 10);

const interactiveObjects = [];
const officeMarker = addBox(1.15, 1.15, 1.15, 0xffca67, 7.4, 4.85, 0, {
  poi: 'office',
});
const bkaiMarker = addBox(1.15, 1.15, 1.15, 0x66d9c8, 7.4, 10.1, 0, {
  poi: 'bkai',
});
interactiveObjects.push(officeMarker, bkaiMarker);

const poiContent = {
  b1: {
    title: 'Nhà B1 và SoICT',
    body:
      'Mô hình low-poly minh họa khu vực quanh Nhà B1. Nguồn chính thức của SoICT công bố Văn phòng Trường CNTT&TT tại Phòng 505, Nhà B1.',
  },
  office: {
    title: 'Văn phòng Trường CNTT&TT',
    body: 'Phòng 505, Nhà B1, Đại học Bách khoa Hà Nội.',
  },
  bkai: {
    title: 'Trung tâm Nghiên cứu Quốc tế về Trí tuệ nhân tạo',
    body: 'Địa chỉ công bố: Tầng 10, Nhà B1, Đại học Bách khoa Hà Nội.',
  },
};

const cameraViews = {
  overview: {
    position: new THREE.Vector3(28, 24, 34),
    target: new THREE.Vector3(0, 5, 0),
  },
  b1: {
    position: new THREE.Vector3(20, 12, 22),
    target: new THREE.Vector3(0, 5.5, 0),
  },
  office: {
    position: new THREE.Vector3(18, 7, 14),
    target: new THREE.Vector3(5.6, 4.85, 0),
  },
  bkai: {
    position: new THREE.Vector3(18, 14, 14),
    target: new THREE.Vector3(5.6, 10.1, 0),
  },
};

let transition = null;
let rotationTimer = null;

function flyTo(endPosition, endTarget, onComplete = null) {
  transition = {
    startPosition: camera.position.clone(),
    endPosition: endPosition.clone(),
    startTarget: controls.target.clone(),
    endTarget: endTarget.clone(),
    startTime: performance.now(),
    duration: 1200,
    onComplete,
  };
  controls.enabled = false;
}

function stopGuidedRotation() {
  window.clearTimeout(rotationTimer);
  rotationTimer = null;
  controls.autoRotate = false;
}

function focusPoi(id, onComplete = null) {
  stopGuidedRotation();
  const view = cameraViews[id];
  flyTo(view.position, view.target, onComplete);
}

function showPoi(id) {
  const content = poiContent[id];
  infoTitle.textContent = content.title;
  infoBody.textContent = content.body;
  infoPanel.classList.remove('hidden');
}

const raycaster = new THREE.Raycaster(
  new THREE.Vector3(),
  new THREE.Vector3(0, 0, -1),
);
const pointer = new THREE.Vector2();

renderer.domElement.addEventListener('click', (event) => {
  const bounds = renderer.domElement.getBoundingClientRect();
  pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
  pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;

  raycaster.setFromCamera(pointer, camera);
  const intersections = raycaster.intersectObjects(interactiveObjects, false);

  if (intersections.length > 0) {
    const id = intersections[0].object.userData.poi;
    showPoi(id);
    focusPoi(id);
  }
});

function animate(time) {
  if (transition) {
    const progress = Math.min((time - transition.startTime) / transition.duration, 1);
    const eased = progress * progress * (3 - 2 * progress);

    camera.position.lerpVectors(
      transition.startPosition,
      transition.endPosition,
      eased,
    );
    controls.target.lerpVectors(
      transition.startTarget,
      transition.endTarget,
      eased,
    );

    if (progress === 1) {
      const onComplete = transition.onComplete;
      transition = null;
      controls.enabled = true;
      if (onComplete) onComplete();
    }
  }

  controls.update();
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Your markers now connect the 3D scene to real location details. Next, you will activate the toolbar controls plus add a guided orbit, reset behaviour, responsive rendering, and a production build check.

Add the Guided Orbit and Validate the Build

Your Three.js scene already turns marker clicks into verified information. It also moves the camera smoothly toward each selected location.

The tour still needs a guided entry point. Visitors also need a reliable way to return to the overview. This step adds those controls before validating the project as a production bundle.

In this step, get ready to:
  • Connect the guided tour controls to the existing camera functions.
  • Keep the renderer responsive across wide and narrow screens.
  • Validate the completed tour with a production build.
Connect the tour controls

The guided orbit uses the existing camera transition before enabling automatic rotation in OrbitControls. A timer stops that rotation after six seconds so the visitor regains control.

  • In main.js, find let transition = null;.
  • Add the timer state directly below that line by copying this declaration:
let rotationTimer = null;

What does this state track?

The rotationTimer variable holds the active timeout. The existing stopGuidedRotation() function uses it to cancel a guided rotation during another camera action.

  • In main.js, find the closing brace of showPoi().
  • Add the guided tour and reset functions directly below showPoi() by copying this code:
function startTour() {
  showPoi('b1');
  focusPoi('b1', () => {
    controls.autoRotate = true;
    rotationTimer = window.setTimeout(() => {
      controls.autoRotate = false;
    }, 6000);
  });
}

function resetView() {
  stopGuidedRotation();
  infoPanel.classList.add('hidden');
  flyTo(cameraViews.overview.position, cameraViews.overview.target);
}

How do these controls work?

  • The startTour() function opens the B1 information before focusing the B1 camera view.
  • The completion callback enables autoRotate after the camera reaches that view.
  • The timeout disables automatic rotation after 6000 milliseconds.
  • The resetView() function stops guided rotation before hiding the panel. It then returns the camera to cameraViews.overview.
  • Save main.js.
  • Refresh the browser tour.

You should still see the complete B1 scene. This confirms that the new functions load without breaking the running module.

Did the scene disappear?

Check that rotationTimer sits below transition. Confirm that both new functions close with matching braces.

Still stuck? Help me find a syntax problem in my startTour() and resetView() functions.

  • In main.js, find the closing brace of the renderer click handler.
  • Connect the toolbar controls below that handler by copying this code:
document.querySelector('#tour-button').addEventListener('click', startTour);
document.querySelector('#reset-button').addEventListener('click', resetView);
document.querySelector('#close-info').addEventListener('click', () => {
  infoPanel.classList.add('hidden');
});

document.querySelectorAll('[data-poi]').forEach((button) => {
  button.addEventListener('click', () => {
    const id = button.dataset.poi;
    showPoi(id);
    focusPoi(id);
  });
});

What do the listeners connect?

  • The #tour-button listener starts the guided camera sequence.
  • The #reset-button listener returns the visitor to the overview.
  • The #close-info listener hides the information panel.
  • Each [data-poi] button reuses showPoi() and focusPoi() with its own location ID.
  • Save main.js.
  • Return to the browser tour from earlier.
  • Refresh the page.
  • Select Văn phòng 505.

You should see the verified office information. The camera should focus on the office marker.

  • Select BKAI tầng 10.

You should see the verified BKAI information. The camera should move toward the upper marker.

  • Select the × close control.

The information panel should disappear while the 3D scene remains available.

  • Select Tham quan B1.

The B1 information should open. The camera should focus on the building before orbiting for six seconds.

  • Select Đặt lại.

That completes the control loop. The panel closes immediately. The camera also returns to the original overview.

Do the toolbar buttons stay inactive?

Check that the listeners appear after the renderer click handler. Confirm that #tour-button and #reset-button match the IDs in index.html.

Need another pair of eyes? Help me debug the button listeners in my B1 tour.

Keep the tour responsive

A responsive canvas needs its drawing buffer to follow the canvas display size. The camera aspect must follow the same dimensions so Nhà B1 keeps its intended proportions.

  • In main.js, find the line immediately before function animate(time).
  • Add the renderer sizing function at that location by copying this code:
function resizeRendererToDisplaySize() {
  const canvas = renderer.domElement;
  const pixelRatio = Math.min(window.devicePixelRatio, 2);
  const width = Math.floor(canvas.clientWidth * pixelRatio);
  const height = Math.floor(canvas.clientHeight * pixelRatio);
  const needsResize = canvas.width !== width || canvas.height !== height;

  if (needsResize) {
    renderer.setSize(width, height, false);
  }

  return needsResize;
}

What does the resize check do?

  • The pixelRatio value caps display density at 2 to limit unnecessary rendering work.
  • The calculated width and height use the canvas display dimensions.
  • The needsResize check prevents repeated buffer updates when the size has not changed.
  • The false argument updates the drawing buffer without replacing the CSS dimensions.
  • In animate(time), find its opening brace.
  • Add the responsive camera update immediately after that brace by copying this code:
  if (resizeRendererToDisplaySize()) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }

Why update the camera too?

The renderer changes the canvas drawing size. Updating camera.aspect keeps the perspective aligned with the canvas shape.

The camera.updateProjectionMatrix() call applies that new aspect ratio before the next frame is rendered.

  • Save main.js.
  • Return to the browser tour.
  • Refresh the page.
  • Drag the browser edge until its width is below 760px.

You should see the toolbar wrap below the introduction card. Nhà B1 should keep its proportions without stretching.

  • Drag the scene with the primary mouse button.
  • Drag the scene with the secondary mouse button.
  • Scroll over the scene.
  • Select Tham quan B1.
  • Restore the browser to a wide layout.

You should retain orbiting plus panning at both widths. Zooming should also remain available. The guided tour should keep the camera correctly framed after the layout changes.

Does the scene look stretched?

Confirm that the resize check is the first block inside animate(time). Check that the aspect ratio uses canvas.clientWidth divided by canvas.clientHeight.

Still seeing distorted geometry? Help me debug responsive Three.js renderer sizing.

✔️ Awesome, I've got everything!

Great. Double-check that you saved main.js before building the production bundle.

ⓧ I'd like to double check the full code

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import './style.css';

const app = document.querySelector('#app');
const infoPanel = document.querySelector('#info-panel');
const infoTitle = document.querySelector('#info-title');
const infoBody = document.querySelector('#info-body');

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  300,
);
camera.position.set(28, 24, 34);

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
app.prepend(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.minDistance = 12;
controls.maxDistance = 72;
controls.maxPolarAngle = Math.PI / 2.05;
controls.target.set(0, 5, 0);
controls.update();

function addBox(width, height, depth, color, x, y, z, data = {}) {
  const geometry = new THREE.BoxGeometry(width, height, depth);
  const material = new THREE.MeshBasicMaterial({ color });
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.set(x, y, z);
  mesh.userData = data;
  scene.add(mesh);
  return mesh;
}

addBox(40, 0.5, 32, 0x5f8d55, 0, -0.25, 0);
addBox(8, 0.08, 32, 0x30373d, -12, 0.04, 0);
addBox(4, 0.09, 25, 0xc7b997, 10, 0.05, 0);
addBox(14, 0.1, 10, 0xb7a98a, 0, 0.06, 10);

const floorHeight = 1.05;
const floorBase = 0.65;

for (let floor = 1; floor <= 10; floor += 1) {
  const y = floorBase + (floor - 1) * floorHeight;
  const color = floor % 2 === 0 ? 0xc8d6df : 0xe0e8ed;
  addBox(12, 1, 7, color, 0, y, 0);
  addBox(10.2, 0.42, 0.12, 0x24425f, 0, y, 3.56);
}

addBox(12.6, 0.35, 7.6, 0xa72a32, 0, 10.75, 0);
addBox(5.2, 0.3, 2.3, 0xa72a32, 0, 1.7, 4.5);
addBox(0.35, 1.7, 0.35, 0xe7ecef, -2.1, 0.9, 4.5);
addBox(0.35, 1.7, 0.35, 0xe7ecef, 2.1, 0.9, 4.5);

function addTree(x, z) {
  addBox(0.35, 1.8, 0.35, 0x76533a, x, 0.9, z);
  addBox(1.6, 1.6, 1.6, 0x2f6f49, x, 2.2, z);
}

[
  [-17, -10],
  [-17, -4],
  [-17, 5],
  [-17, 11],
  [14, -10],
  [14, -4],
  [14, 5],
  [14, 11],
  [8, -11],
  [3, -11],
].forEach(([x, z]) => addTree(x, z));

addBox(7, 2.8, 5, 0x617788, -16, 1.4, -11);
addBox(8, 3.8, 5, 0x718698, 15, 1.9, 10);

const interactiveObjects = [];
const officeMarker = addBox(1.15, 1.15, 1.15, 0xffca67, 7.4, 4.85, 0, {
  poi: 'office',
});
const bkaiMarker = addBox(1.15, 1.15, 1.15, 0x66d9c8, 7.4, 10.1, 0, {
  poi: 'bkai',
});
interactiveObjects.push(officeMarker, bkaiMarker);

const poiContent = {
  b1: {
    title: 'Nhà B1 và SoICT',
    body:
      'Mô hình low-poly minh họa khu vực quanh Nhà B1. Nguồn chính thức của SoICT công bố Văn phòng Trường CNTT&TT tại Phòng 505, Nhà B1.',
  },
  office: {
    title: 'Văn phòng Trường CNTT&TT',
    body: 'Phòng 505, Nhà B1, Đại học Bách khoa Hà Nội.',
  },
  bkai: {
    title: 'Trung tâm Nghiên cứu Quốc tế về Trí tuệ nhân tạo',
    body: 'Địa chỉ công bố: Tầng 10, Nhà B1, Đại học Bách khoa Hà Nội.',
  },
};

const cameraViews = {
  overview: {
    position: new THREE.Vector3(28, 24, 34),
    target: new THREE.Vector3(0, 5, 0),
  },
  b1: {
    position: new THREE.Vector3(20, 12, 22),
    target: new THREE.Vector3(0, 5.5, 0),
  },
  office: {
    position: new THREE.Vector3(18, 7, 14),
    target: new THREE.Vector3(5.6, 4.85, 0),
  },
  bkai: {
    position: new THREE.Vector3(18, 14, 14),
    target: new THREE.Vector3(5.6, 10.1, 0),
  },
};

let transition = null;
let rotationTimer = null;

function flyTo(endPosition, endTarget, onComplete = null) {
  transition = {
    startPosition: camera.position.clone(),
    endPosition: endPosition.clone(),
    startTarget: controls.target.clone(),
    endTarget: endTarget.clone(),
    startTime: performance.now(),
    duration: 1200,
    onComplete,
  };
  controls.enabled = false;
}

function stopGuidedRotation() {
  window.clearTimeout(rotationTimer);
  rotationTimer = null;
  controls.autoRotate = false;
}

function focusPoi(id, onComplete = null) {
  stopGuidedRotation();
  const view = cameraViews[id];
  flyTo(view.position, view.target, onComplete);
}

function showPoi(id) {
  const content = poiContent[id];
  infoTitle.textContent = content.title;
  infoBody.textContent = content.body;
  infoPanel.classList.remove('hidden');
}

function startTour() {
  showPoi('b1');
  focusPoi('b1', () => {
    controls.autoRotate = true;
    rotationTimer = window.setTimeout(() => {
      controls.autoRotate = false;
    }, 6000);
  });
}

function resetView() {
  stopGuidedRotation();
  infoPanel.classList.add('hidden');
  flyTo(cameraViews.overview.position, cameraViews.overview.target);
}

const raycaster = new THREE.Raycaster(
  new THREE.Vector3(),
  new THREE.Vector3(0, 0, -1),
);
const pointer = new THREE.Vector2();

renderer.domElement.addEventListener('click', (event) => {
  const bounds = renderer.domElement.getBoundingClientRect();
  pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
  pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;

  raycaster.setFromCamera(pointer, camera);
  const intersections = raycaster.intersectObjects(interactiveObjects, false);

  if (intersections.length > 0) {
    const id = intersections[0].object.userData.poi;
    showPoi(id);
    focusPoi(id);
  }
});

document.querySelector('#tour-button').addEventListener('click', startTour);
document.querySelector('#reset-button').addEventListener('click', resetView);
document.querySelector('#close-info').addEventListener('click', () => {
  infoPanel.classList.add('hidden');
});

document.querySelectorAll('[data-poi]').forEach((button) => {
  button.addEventListener('click', () => {
    const id = button.dataset.poi;
    showPoi(id);
    focusPoi(id);
  });
});

function resizeRendererToDisplaySize() {
  const canvas = renderer.domElement;
  const pixelRatio = Math.min(window.devicePixelRatio, 2);
  const width = Math.floor(canvas.clientWidth * pixelRatio);
  const height = Math.floor(canvas.clientHeight * pixelRatio);
  const needsResize = canvas.width !== width || canvas.height !== height;

  if (needsResize) {
    renderer.setSize(width, height, false);
  }

  return needsResize;
}

function animate(time) {
  if (resizeRendererToDisplaySize()) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }

  if (transition) {
    const progress = Math.min((time - transition.startTime) / transition.duration, 1);
    const eased = progress * progress * (3 - 2 * progress);

    camera.position.lerpVectors(
      transition.startPosition,
      transition.endPosition,
      eased,
    );
    controls.target.lerpVectors(
      transition.startTarget,
      transition.endTarget,
      eased,
    );

    if (progress === 1) {
      const onComplete = transition.onComplete;
      transition = null;
      controls.enabled = true;
      if (onComplete) onComplete();
    }
  }

  controls.update();
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);
Build the production bundle

The development server proves the tour works during editing. A production build asks Vite to process the completed project into files that can be deployed later.

  • Switch back to the terminal running the development server.
  • Stop the development server by pressing Ctrl+C.

Before you run the build, do you expect every module and dependency to pass Vite's production checks?

  • Validate the completed project by running this command:
npm run build

What does the build prove?

The build script in package.json runs Vite's production build. Vite processes the module imports before writing the bundle to dist.

A completed command without an error confirms that the final JavaScript can be bundled for production.

You should see the build finish without a terminal error. Vite creates the dist directory for the production bundle.

  • Return to VSCodium.
  • Confirm that the dist directory appears in the file sidebar.

Did the production build fail?

Read the first file reference in the terminal output. Compare that location with the matching section in the full main.js reference above.

Need help interpreting the failure? Help me debug my failed Vite production build.

Your B1 mini-tour is now complete. Visitors can explore the scene manually or follow a guided orbit. The successful production build confirms that the project is ready for future deployment work.

Secret mission

Create the Accuracy Backlog

Audit your conceptual B1 model against three permitted reference views. Rank at least five evidence-backed corrections. Define one bounded expansion area that waits for official map evidence or on-site measurements.

Clean Up Your Resources

Clean Up Your Resources

Your local soict-b1-tour project has no ongoing service costs. Choose the option below that matches whether you want to keep, pause, or delete it.

Resources you used:

  • Local source files in the soict-b1-tour folder: package.json, index.html, style.css, and main.js.
  • The dist production bundle inside soict-b1-tour.
  • The B1-accuracy-backlog.md evidence backlog inside soict-b1-tour.

Keep everything running

No action needed. Choose this if you plan to keep improving the B1 tour or use it as the foundation for your next campus-map project.

  • Keep the soict-b1-tour folder in its current location.
  • Retain the dist bundle as the validated production output.
  • Use B1-accuracy-backlog.md to guide future corrections without introducing unverified architecture or coordinates.

Pause - I'll come back to this later

Shut down your editing session while preserving the completed tour. Your local files remain ready for the next time you work on them.

  • Save any unsaved changes in VSCodium.
  • Close VSCodium.
  • Return to the soict-b1-tour folder in VSCodium when you are ready to continue.

Delete - I don't want to use this again

This option removes the complete local project. It also removes the production bundle and accuracy backlog stored inside the project folder.

Deletion is permanent

Deleting soict-b1-tour removes every project artifact listed above.

  • Copy any permitted reference material or backlog notes you want to keep to a location outside soict-b1-tour.
  • Use the VSCodium window from earlier to open a terminal in soict-b1-tour.
  • Confirm the terminal location by running this command:
pwd

Check Your Location

The final directory name in the output should be soict-b1-tour. This check ensures the deletion command targets the completed project.

  • Move one directory above the project and delete soict-b1-tour by running these commands:
cd ..
rm -rf soict-b1-tour

What Do These Commands Do?

  • The first line moves the terminal to the directory that contains the project folder.
  • The second line permanently removes soict-b1-tour and every file inside it.
  • Verify that the project folder is gone by listing the remaining files:
ls

What Should You See?

That cleanup is complete. The directory listing should no longer include soict-b1-tour.

Still See the Project Folder?

  • Confirm that the terminal path before deletion ended with soict-b1-tour.
  • Check whether the folder name appears in the latest ls output before repeating the deletion commands.

Help me remove the local project safely.

Nice Work!

Nice Work!

You did it! You built a responsive browser-based tour of Nhà B1 with Three.js.

You've learned how to:

  • Build a low-poly 3D scene with a perspective camera. The scene includes a renderer plus meshes for Nhà B1 and its conceptual surroundings.
  • Add interactive navigation with OrbitControls. Visitors can orbit around the building. They can pan across the area. They can zoom between overview and detail views.
  • Use raycasting to select the verified SoICT and BKAI markers. Each marker opens the correct information panel. Smooth camera transitions focus the selected location. A guided orbit and reset control make the tour ready to demonstrate. A successful Vite build produces the dist production bundle.
  • Secret Mission: Created an evidence-driven accuracy backlog in B1-accuracy-backlog.md. It records three permitted reference views. It ranks at least five visual corrections. It bounds the next map area without guessed coordinates. It identifies the official sources or on-site measurements required before expansion.

Ready to quiz yourself?