Zbuduj podgląd klocków przy użyciu oświetlenia

1. Wprowadzenie

Komponenty sieciowe

Komponenty internetowe to zbiór standardów internetowych, które umożliwiają twórcom rozszerzanie HTML o elementy niestandardowe. W tym ćwiczeniu zdefiniujesz element <brick-viewer>, który będzie mógł wyświetlać modele z klocków.

lit-element

Aby zdefiniować nasz element niestandardowy <brick-viewer>, użyjemy lit-element. lit-element to lekka klasa bazowa, która dodaje do standardu komponentów internetowych pewne ułatwienia składniowe. Ułatwi nam to rozpoczęcie korzystania z elementu niestandardowego.

Rozpocznij

Będziemy pisać kod w środowisku online Stackblitz, więc otwórz ten link w nowym oknie:

stackblitz.com/edit/brick-viewer

Zaczynajmy!

2. Definiowanie elementu niestandardowego

Definicja klasy

Aby zdefiniować element niestandardowy, utwórz klasę, która rozszerza LitElement, i oznacz ją dekoratorem @customElement. Argumentem funkcji @customElement będzie nazwa elementu niestandardowego.

W pliku brick-viewer.ts wpisz:

@customElement('brick-viewer')
export class BrickViewer extends LitElement {
}

Element <brick-viewer></brick-viewer> jest teraz gotowy do użycia w HTML. Jeśli jednak spróbujesz, nic się nie wyświetli. Zajmijmy się tym.

Metoda renderowania

Aby zaimplementować widok komponentu, zdefiniuj metodę o nazwie render. Ta metoda powinna zwracać literał szablonu oznaczony funkcją html. Wstaw dowolny kod HTML do oznaczonego literału szablonu. Będzie się wyświetlać, gdy użyjesz <brick-viewer>.

Dodaj metodę render:

export class BrickViewer extends LitElement {
  render() {
    return html`<div>Brick viewer</div>`;
  }
}

3. Określanie pliku LDraw

Definiowanie usługi

Dobrze byłoby, gdyby użytkownik <brick-viewer> mógł określić, który model klocka ma być wyświetlany, za pomocą atrybutu, np. w ten sposób:

<brick-viewer src="path/to/model.ldraw"></brick-viewer>

Ponieważ tworzymy element HTML, możemy skorzystać z deklaratywnego interfejsu API i określić atrybut źródła, tak jak w przypadku tagu <img> lub <video>. W lit-element wystarczy udekorować właściwość klasy za pomocą @property. Opcja type pozwala określić, w jaki sposób lit-element analizuje właściwość do użycia jako atrybut HTML.

Zdefiniuj właściwość i atrybut src:

export class BrickViewer extends LitElement {
  @property({type: String})
  src: string|null = null;
}

Element <brick-viewer> ma teraz atrybut src, który możemy ustawić w HTML-u. Dzięki lit-element wartość jest już odczytywana z klasy BrickViewer.

Wyświetlanie wartości

Wartość atrybutu src możemy wyświetlić, używając jej w literałach szablonu metody renderowania. Interpoluj wartości w literałach szablonu za pomocą składni ${value}.

export class BrickViewer extends LitElement {
  render() {
    return html`<div>Brick model: ${this.src}</div>`;
  }
}

W oknie widzimy teraz wartość atrybutu src w elemencie <brick-viewer>. Spróbuj otworzyć narzędzia programisty w przeglądarce i ręcznie zmienić atrybut src. Śmiało, spróbuj...

...Czy zauważyłeś, że tekst w elemencie aktualizuje się automatycznie? lit-element obserwuje właściwości klasy oznaczone dekoratorem @property i ponownie renderuje widok. lit-element wykonuje za Ciebie całą pracę.

4. Tworzenie sceny za pomocą Three.js

Światła, kamera, renderowanie!

Nasz element niestandardowy będzie używać biblioteki three.js do renderowania modeli klocków 3D. Niektóre czynności chcemy wykonać tylko raz dla każdej instancji elementu <brick-viewer>, np. skonfigurować scenę, kamerę i oświetlenie w three.js. Dodamy je do konstruktora klasy BrickViewer. Niektóre obiekty zachowamy jako właściwości klasy, aby móc ich później użyć: camera, scene, controls i renderer.

Dodaj konfigurację sceny three.js:

export class BrickViewer extends LitElement {

  private _camera: THREE.PerspectiveCamera;
  private _scene: THREE.Scene;
  private _controls: OrbitControls;
  private _renderer: THREE.WebGLRenderer;

  constructor() {
    super();

    this._camera = new THREE.PerspectiveCamera(45, this.clientWidth/this.clientHeight, 1, 10000);
    this._camera.position.set(150, 200, 250);

    this._scene = new THREE.Scene();
    this._scene.background = new THREE.Color(0xdeebed);

    const ambientLight = new THREE.AmbientLight(0xdeebed, 0.4);
    this._scene.add( ambientLight );

    const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
    directionalLight.position.set(-1000, 1200, 1500);
    this._scene.add(directionalLight);

    this._renderer = new THREE.WebGLRenderer({antialias: true});
    this._renderer.setPixelRatio(window.devicePixelRatio);
    this._renderer.setSize(this.offsetWidth, this.offsetHeight);

    this._controls = new OrbitControls(this._camera, this._renderer.domElement);
    this._controls.addEventListener("change", () =>
      requestAnimationFrame(this._animate)
    );

    this._animate();

    const resizeObserver = new ResizeObserver(this._onResize);
    resizeObserver.observe(this);
  }

  private _onResize = (entries: ResizeObserverEntry[]) => {
    const { width, height } = entries[0].contentRect;
    this._renderer.setSize(width, height);
    this._camera.aspect = width / height;
    this._camera.updateProjectionMatrix();
    requestAnimationFrame(this._animate);
  };

  private _animate = () => {
    this._renderer.render(this._scene, this._camera);
  };
}

Obiekt WebGLRenderer udostępnia element DOM, który wyświetla wyrenderowaną scenę three.js. Dostęp do niej można uzyskać za pomocą usługi domElement. Możemy wstawić tę wartość do literału szablonu renderowania za pomocą składni ${value}.

Usuń z szablonu komunikat src i wstaw element DOM renderera:

export class BrickViewer extends LitElement {
  render() {
    return html`
      ${this._renderer.domElement}
    `;
  }
}

Aby umożliwić wyświetlanie elementu DOM renderera w całości, musimy też ustawić sam element <brick-viewer> na display: block. Możemy udostępnić style w statycznej właściwości o nazwie styles, ustawionej na literał szablonu css.

Dodaj do klasy ten styl:

export class BrickViewer extends LitElement {
  static styles = css`
    /* The :host selector styles the brick-viewer itself! */
    :host {
      display: block;
    }
  `;
}

Teraz <brick-viewer> wyświetla renderowaną scenę three.js:

Element brick-viewer wyświetlający wyrenderowaną, ale pustą scenę.

Ale… jest pusta. Podajmy model.

Moduł wczytujący klocki

Zdefiniowaną wcześniej właściwość src przekażemy do klasy LDrawLoader, która jest dostarczana z biblioteką three.js.

Pliki LDraw mogą dzielić model klocka na osobne etapy budowy. Łączna liczba kroków i widoczność poszczególnych klocków są dostępne za pomocą interfejsu LDrawLoader API.

Skopiuj te właściwości, nową metodę _loadModel i nowy wiersz w konstruktorze:

@customElement('brick-viewer')
export class BrickViewer extends LitElement {
  private _loader = new LDrawLoader();
  private _model: any;
  private _numConstructionSteps?: number;
  step?: number;

  constructor() {
    // ...
    // Add this line right before this._animate();
    (this._loader as any).separateObjects = true;
    this._animate();
  }

  private _loadModel() {
    if (this.src === null) {
      return;
    }
    this._loader
        .setPath('')
        // Using our src property!
        .load(this.src, (newModel) => {

          if (this._model !== undefined) {
            this._scene.remove(this._model);
            this._model = undefined;
          }

          this._model = newModel;

          // Convert from LDraw coordinates: rotate 180 degrees around OX
          this._model.rotation.x = Math.PI;
          this._scene.add(this._model);

          this._numConstructionSteps = this._model.userData.numConstructionSteps;
          this.step = this._numConstructionSteps!;

          const bbox = new THREE.Box3().setFromObject(this._model);
          this._controls.target.copy(bbox.getCenter(new THREE.Vector3()));
          this._controls.update();
          this._controls.saveState();
        });
  }
}

Kiedy należy wywołać funkcję _loadModel? Musi być wywoływana za każdym razem, gdy zmienia się atrybut src. Dekorując właściwość src za pomocą @property, włączyliśmy ją do cyklu życia aktualizacji lit-element. Gdy wartość jednej z tych właściwości ulegnie zmianie, wywoływana jest seria metod, które mogą uzyskać dostęp do nowych i starych wartości właściwości. Metoda cyklu życia, która nas interesuje, to update. Metoda update przyjmuje argument PropertyValues, który zawiera informacje o właściwościach, które właśnie uległy zmianie. To idealne miejsce na _loadModel.

Dodaj metodę update:

export class BrickViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    if (changedProperties.has('src')) {
      this._loadModel();
    }
    super.update(changedProperties);
  }
}

Element <brick-viewer> może teraz wyświetlać plik cegiełki określony za pomocą atrybutu src.

Element przeglądarki klocków wyświetlający model samochodu.

5. Wyświetlanie częściowych modeli

Teraz skonfigurujmy bieżący etap budowy. Chcemy móc określić <brick-viewer step="5"></brick-viewer> i zobaczyć, jak wygląda model z klocków na 5 etapie budowy. Aby to zrobić, przekształćmy właściwość step we właściwość obserwowaną, dodając do niej dekorator @property.

Dodaj parametry do właściwości step:

export class BrickViewer extends LitElement {
  @property({type: Number})
  step?: number;
}

Teraz dodamy metodę pomocniczą, która sprawi, że widoczne będą tylko klocki do bieżącego etapu budowy. Wywołamy funkcję pomocniczą w metodzie aktualizacji, aby była uruchamiana za każdym razem, gdy zmieni się właściwość step.

Zaktualizuj metodę update i dodaj nową metodę _updateBricksVisibility:

export class BrickViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    if (changedProperties.has('src')) {
      this._loadModel();
    }
    if (changedProperties.has('step')) {
      this._updateBricksVisibility();
    }
    super.update(changedProperties);
  }

  private _updateBricksVisibility() {
    this._model && this._model.traverse((c: any) => {
      if (c.isGroup && this.step) {
        c.visible = c.userData.constructionStep <= this.step;
      }
    });
    requestAnimationFrame(this._animate);
  }
}

Otwórz teraz narzędzia dla deweloperów w przeglądarce i sprawdź element <brick-viewer>. Dodaj do niego atrybut step, np. tak:

Kod HTML elementu Brick Viewer z atrybutem step ustawionym na 10.

Zobacz, co się stanie z wyrenderowanym modelem. Za pomocą atrybutu step możemy kontrolować, jaka część modelu jest wyświetlana. Gdy atrybut step ma wartość "10", powinno to wyglądać tak:

Model z klocków, który ma tylko 10 etapów budowy.

6. Nawigacja po zestawie klocków

mwc-icon-button

Użytkownik końcowy naszego <brick-viewer> powinien też mieć możliwość poruszania się po krokach kompilacji za pomocą interfejsu. Dodajmy przyciski umożliwiające przejście do następnego kroku, poprzedniego kroku i pierwszego kroku. Aby ułatwić to zadanie, użyjemy komponentu internetowego przycisku Material Design. @material/mwc-icon-button jest już zaimportowany, więc możemy dodać <mwc-icon-button></mwc-icon-button>. Ikona, której chcemy użyć, może być określona za pomocą atrybutu icon, np. <mwc-icon-button icon="thumb_up"></mwc-icon-button>. Wszystkie możliwe ikony znajdziesz tutaj: material.io/resources/icons.

Dodajmy do metody renderowania kilka przycisków z ikonami:

export class BrickViewer extends LitElement {
  render() {
    return html`
      ${this._renderer.domElement}
      <div id="controls">
        <mwc-icon-button icon="replay"></mwc-icon-button>
        <mwc-icon-button icon="navigate_before"></mwc-icon-button>
        <mwc-icon-button icon="navigate_next"></mwc-icon-button>
      </div>
    `;
  }
}

Dzięki komponentom internetowym korzystanie z Material Design na naszej stronie jest bardzo proste.

Powiązania zdarzeń

Te przyciski powinny faktycznie coś robić. Przycisk „Odpowiedz” powinien zresetować krok konstrukcyjny do 1. Przycisk „navigate_before” powinien zmniejszać numer kroku konstrukcji, a przycisk „navigate_next” powinien go zwiększać. lit-element ułatwia dodawanie tej funkcji za pomocą powiązań zdarzeń. W literałach szablonu HTML użyj składni @eventname=${eventHandler} jako atrybutu elementu. eventHandler będzie teraz uruchamiana, gdy na tym elemencie zostanie wykryte zdarzenie eventname. Dodajmy na przykład obsługę zdarzeń kliknięcia do 3 przycisków z ikonami:

export class BrickViewer extends LitElement {
  private _restart() {
    this.step! = 1;
  }

  private _stepBack() {
    this.step! -= 1;
  }

  private _stepForward() {
    this.step! += 1;
  }

  render() {
    return html`
      ${this._renderer.domElement}
      <div id="controls">
        <mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
        <mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
        <mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
      </div>
    `;
  }
}

Spróbuj teraz kliknąć przyciski. Świetnie!

Style

Przyciski działają, ale nie wyglądają dobrze. Wszystkie są stłoczone na dole. Nadajmy im styl, aby nałożyć je na scenę.

Aby zastosować style do tych przycisków, wracamy do właściwości static styles. Te style mają ograniczony zakres, co oznacza, że będą stosowane tylko do elementów w tym komponencie internetowym. To jedna z zalet pisania komponentów internetowych: selektory mogą być prostsze, a kod CSS będzie łatwiejszy do odczytania i napisania. Pa, BEM!

Zaktualizuj style, aby wyglądały tak:

export class BrickViewer extends LitElement {
  static styles = css`
    :host {
      display: block;
      position: relative;
    }
    #controls {
      position: absolute;
      bottom: 0;
      width: 100%;
      display: flex;
    }
  `;
}

Element przeglądarki klocków z przyciskami ponownego uruchomienia, cofania i przewijania do przodu.

Przycisk resetowania kamery

Użytkownicy naszych <brick-viewer> mogą obracać scenę za pomocą myszy. Dodajmy też przycisk resetowania kamery do pozycji domyślnej. Inny element <mwc-icon-button> z powiązaniem zdarzenia kliknięcia wystarczy.

export class BrickViewer extends LitElement {
  private _resetCamera() {
    this._controls.reset();
  }

  render() {
    return html`
      <div id="controls">
        <!-- ... -->
        <!-- Add this button: -->
        <mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
      </div>
    `;
  }
}

Szybsza nawigacja

Niektóre zestawy klocków mają wiele kroków. Użytkownik może chcieć przejść do określonego kroku. Dodanie suwaka z numerami kroków może ułatwić szybkie poruszanie się po instrukcjach. W tym celu użyjemy elementu <mwc-slider>.

mwc-slider

Komponent suwaka wymaga kilku ważnych danych, takich jak minimalna i maksymalna wartość suwaka. Minimalna wartość suwaka może zawsze wynosić „1”. Maksymalna wartość suwaka powinna wynosić this._numConstructionSteps, jeśli model został wczytany. <mwc-slider> te wartości na podstawie atrybutów. Możemy też użyć ifDefined dyrektywy lit-html, aby uniknąć ustawiania atrybutu max, jeśli właściwość _numConstructionSteps nie została zdefiniowana.

Dodaj znak <mwc-slider> między przyciskami „Wstecz” i „Dalej”:

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ... backwards button -->
        <!-- Add this slider: -->
        <mwc-slider
            step="1"
            pin
            markers
            min="1"
            max=${ifDefined(this._numConstructionSteps)}
        ></mwc-slider>
        <!-- ... forwards button -->
      </div>
    `;
  }
}

Dane „w górę”

Gdy użytkownik przesunie suwak, bieżący etap budowy powinien się zmienić, a widoczność modelu powinna zostać odpowiednio zaktualizowana. Element suwaka będzie emitować zdarzenie input za każdym razem, gdy suwak zostanie przeciągnięty. Dodaj powiązanie zdarzenia na samym suwaku, aby przechwycić to zdarzenie i zmienić krok konstrukcji.

Dodaj powiązanie wydarzenia:

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ...  -->
        <!-- Add the @input event binding: -->
        <mwc-slider
            ...
            @input=${(e: CustomEvent) => this.step = e.detail.value}
        ></mwc-slider>
        <!-- ... -->
      </div>
    `;
  }
}

Super! Za pomocą suwaka możemy zmienić wyświetlany krok.

Dane „w dół”

Jest jeszcze jedna sprawa. Gdy do zmiany kroku używane są przyciski „Wstecz” i „Dalej”, należy zaktualizować uchwyt suwaka. Powiąż atrybut wartości <mwc-slider> z atrybutem this.step.

Dodaj powiązanie value:

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ...  -->
        <!-- Add the value property binding: -->
        <mwc-slider
            ...
            value=${ifDefined(this.step)}
        ></mwc-slider>
        <!-- ... -->
      </div>
    `;
  }
}

Suwak jest już prawie gotowy. Dodaj styl elastyczny, aby dobrze współpracował z innymi elementami sterującymi:

export class BrickViewer extends LitElement {
  static styles = css`
    /* ... */
    mwc-slider {
      flex-grow: 1;
    }
  `;
}

Musimy też wywołać metodę layout w samym elemencie suwaka. Zrobimy to w metodzie cyklu życia firstUpdated, która jest wywoływana po pierwszym ułożeniu DOM. Dekorator query może pomóc nam uzyskać odniesienie do elementu suwaka w szablonie.

export class BrickViewer extends LitElement {
  @query('mwc-slider')
  slider!: Slider|null;

  async firstUpdated() {
    if (this.slider) {
      await this.slider.updateComplete
      this.slider.layout();
    }
  }
}

Oto wszystkie dodatki do suwaka (z dodatkowymi atrybutami pinmarkers, które sprawiają, że suwak wygląda atrakcyjnie):

export class BrickViewer extends LitElement {
 @query('mwc-slider')
 slider!: Slider|null;

 static styles = css`
   /* ... */
   mwc-slider {
     flex-grow: 1;
   }
 `;

 async firstUpdated() {
   if (this.slider) {
     await this.slider.updateComplete
     this.slider.layout();
   }
 }

 render() {
   return html`
     ${this._renderer.domElement}
     <div id="controls">
       <mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
       <mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
       <mwc-slider
         step="1"
         pin
         markers
         min="1"
         max=${ifDefined(this._numConstructionSteps)}
         ?disabled=${this._numConstructionSteps === undefined}
         value=${ifDefined(this.step)}
         @input=${(e: CustomEvent) => this.constructionStep = e.detail.value}
       ></mwc-slider>
       <mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
       <mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
     </div>
   `;
 }
}

Oto gotowy produkt.

Poruszanie się po modelu samochodu z klocków za pomocą elementu brick-viewer

7. Podsumowanie

Dowiedzieliśmy się wiele o tym, jak używać lit-element do tworzenia własnych elementów HTML. Dowiedzieliśmy się, jak:

  • Definiowanie elementu niestandardowego
  • Deklarowanie interfejsu API atrybutu
  • Renderowanie widoku elementu niestandardowego
  • Hermetyzacja stylów
  • Przekazywanie danych za pomocą zdarzeń i właściwości

Jeśli chcesz dowiedzieć się więcej o lit-element, odwiedź jego oficjalną stronę.

Gotowy element brick-viewer możesz zobaczyć na stronie stackblitz.com/edit/brick-viewer-complete.

brick-viewer jest też dostępny w NPM. Kod źródłowy możesz wyświetlić w repozytorium GitHub.