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

1. Wprowadzenie

Komponenty sieciowe

Komponenty sieciowe to zbiór standardów internetowych, które pozwalają programistom rozszerzać kod HTML o elementy niestandardowe. W tym ćwiczeniu zdefiniujesz element <brick-viewer>, który będzie wyświetlać modele klocków.

element świetlny

Aby ułatwić nam zdefiniowanie elementu niestandardowego <brick-viewer>, używamy elementu lit-element. Jest to lekka klasa bazowa, która dodaje do standardu komponentów sieciowych trochę cukru składniowego. Dzięki temu łatwiej będzie nam uruchomić element niestandardowy.

Rozpocznij

Będziemy kodować w online’owym środowisku 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 zakres LitElement, i dekoruj ją za pomocą @customElement. Argument @customElement to nazwa elementu niestandardowego.

W pliku brick-viewer.ts wpisz:

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

Teraz element <brick-viewer></brick-viewer> jest gotowy do użycia w kodzie HTML. Jeśli jednak spróbujesz to zrobić, nic się nie wyświetli. Zajmijmy się tym.

Metoda renderowania

Aby zaimplementować widok komponentu, zdefiniuj metodę o nazwie „renderowanie”. Ta metoda powinna zwracać literał szablonu otagowany funkcją html. W tagowanym szablonie dosłownym umieść dowolny kod HTML. Zostanie ono wyrenderowane, gdy użyjesz atrybutu <brick-viewer>.

Dodaj metodę render:

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

3. Określanie pliku LDraw

Zdefiniuj usługę

Dobrze byłoby, gdyby użytkownik <brick-viewer> mógł określić, który model klocka ma się wyświetlać, za pomocą atrybutu, na przykład:

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

Tworzymy element HTML, więc możemy wykorzystać interfejs API deklaratywnego i zdefiniować atrybut źródła, tak jak w przypadku tagu <img> lub <video>. Z elementem oświetleniowym jest to równie proste jak udekorowanie obiektu klasowego za pomocą @property. Opcja type pozwala określić sposób analizowania właściwości przez element świetlny na potrzeby użycia jej jako atrybutu HTML.

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

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

<brick-viewer> ma teraz atrybut src, który można ustawić w kodzie HTML. Jego wartość jest już czytelna w klasie BrickViewer dzięki elementowi lit-element.

Wyświetlam wartości

Możemy wyświetlić wartość atrybutu src, używając jej w literalu szablonu metody renderowania. Interpoluj wartości na literały szablonów z użyciem 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 wykonać to działanie: otwórz narzędzia dla programistów w przeglądarce i ręcznie zmień atrybut src. Śmiało, wypróbuj...

...Czy zauważyłeś/zauważyłaś, że tekst w elemencie jest aktualizowany automatycznie? Komponent lit-element obserwuje właściwości klasy ozdobione symbolem @property i ponownie renderuje widok. Komponent lit-element wykonuje ciężką pracę, aby nie musieć tego robić.

4. Przygotowanie sceny za pomocą Three.js

Światła, kamera, renderuj!

Nasz element niestandardowy użyje pliku trzy.js do renderowania klocków 3D. W niektórych przypadkach chcemy robić tylko raz na każde wystąpienie elementu <brick-viewer>, na przykład skonfigurować scenę, kamerę i oświetlenie. Dodamy je do konstruktora klasy BrickViewer. Niektóre obiekty zachowamy jako właściwości klasy, żeby móc ich później użyć: kamera, scena, elementy sterujące i mechanizm renderowania.

Dodaj konfigurację sceny trzy.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 zawiera element DOM, który wyświetla wyrenderowaną scenę trzy.js. Dostęp do niego uzyskujesz w usłudze domElement. Możemy interpolować tę wartość w literale szablonu renderowania, używając składni ${value}.

Usuń wiadomość src z szablonu i wstaw element DOM mechanizmu renderowania:

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

Aby umożliwić wyświetlanie całego elementu DOM mechanizmu renderowania, element <brick-viewer> musi też mieć wartość display: block. Możemy dostarczyć style w właściwości statycznej o nazwie styles ustawionej na literał szablonu css.

Dodaj do zajęć 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 wyrenderowaną scenę trzy.js:

Element Brick Viewer wyświetlający wyrenderowaną, ale pustą scenę.

Ale... jest pusta. Przekażemy mu model.

Ładowarka do cegieł

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

Pliki LDraw pozwalają rozdzielać model ceglany na osobne etapy budynku. Łączna liczba kroków i widoczność poszczególnych klocków są dostępne przez interfejs 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ływany za każdym razem, gdy atrybut src ulegnie zmianie. Udekorowanie usługi src za pomocą @property oznacza, że usługa została włączona do cyklu życia aktualizacji elementów oświetlenia. Gdy wartość jednej z tych właściwości dekoracji zmieni się, 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 się zmieniły. To idealne miejsce, by zadzwonić pod numer _loadModel.

Dodaj metodę update:

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

Nasz element <brick-viewer> może teraz wyświetlać plik ceglany określony za pomocą atrybutu src.

Ceglany element przedstawiający model samochodu.

5. Wyświetlanie modeli cząstkowych

Skonfigurujmy teraz bieżący krok tworzenia. Chcielibyśmy móc określić <brick-viewer step="5"></brick-viewer>i zobaczyć, jak wygląda model cegły na 5. etapie budowy. W tym celu ustawmy właściwość step jako właściwość obserwowaną, dekorując ją atrybutem @property.

Dekoruj właściwość step:

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

Teraz dodamy metodę pomocniczą, która sprawi, że widoczne będą tylko elementy do bieżącego kroku tworzenia. W metodzie update wywołamy pomocnika, aby działał 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);
  }
}

Teraz otwórz narzędzia dla programistów w przeglądarce i sprawdź element <brick-viewer>. Dodaj do niego atrybut step w ten sposób:

Kod HTML elementu „ceglany podgląd” z atrybutem kroku ustawionym na wartość 10.

Zobacz, co się dzieje z wyrenderowanym modelem. Atrybutu step można użyć do kontrolowania, ile modelu ma się wyświetlać. Gdy atrybut step ma wartość "10", powinno to wyglądać tak:

Ceglany model z zaledwie 10 etapami konstrukcyjnymi.

6. Nawigacja po zestawie klocków

mwc-icon-button

Użytkownik końcowy <brick-viewer> powinien też mieć możliwość przechodzenia przez etapy kompilacji w interfejsie. Dodajmy przyciski przechodzenia do następnego, poprzedniego kroku i pierwszego kroku. Aby ułatwić ten proces, użyjemy komponentu internetowego przycisków w stylu Material Design. Ponieważ @material/mwc-icon-button jest już zaimportowany, możemy wstawić <mwc-icon-button></mwc-icon-button>. W atrybucie ikony możemy określić ikonę, której chcemy używać z atrybutem ikony, na przykład: <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 ikon:

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 coś robić. Przycisk „Odpowiedz” powinien zresetować krok tworzenia do 1. Przycisk „Navigation_before” powinien zmniejszać krok tworzenia, a przycisk „ nawiguj_next” powinien zwiększać jego wartość. Element lit-element ułatwia dodanie tej funkcji z powiązaniami zdarzeń. W literale szablonu HTML użyj składni @eventname=${eventHandler} jako atrybutu elementu. Funkcja eventHandler będzie teraz działać, gdy na tym elemencie zostanie wykryte zdarzenie eventname. Na przykład dodajmy moduły obsługi zdarzeń kliknięcia do trzech przycisków ikon:

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ąda to dobrze. Wszystkie są zebrane na dole. Nadaj im styl, aby nałożyć je na scenę.

Aby zastosować style do tych przycisków, wracamy do usługi static styles. Style te mają ograniczony zakres, co oznacza, że będą stosowane tylko do elementów w tym komponencie internetowym. To jedna z przyjemności płynących z tworzenia komponentów internetowych: selektory mogą być prostsze, a CSS łatwiejszy do odczytania i zapisu. Do widzenia, BEM!

Zmień 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 widza klocka z przyciskami ponownego uruchamiania, wstecz i do przodu.

Przycisk resetowania aparatu

Użytkownicy naszego narzędzia <brick-viewer> mogą obracać scenę za pomocą myszy. Dodajmy teraz przyciski, które służą do resetowania kamery do pozycji domyślnej. Inny element <mwc-icon-button> z powiązaniem zdarzenia kliknięcia spełni to zadanie.

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 przyspieszyć nawigację. Wykorzystamy do tego element <mwc-slider>.

mwc-slider

Element suwaka wymaga kilku ważnych danych, takich jak minimalna i maksymalna wartość suwaka. Minimalna wartość suwaka zawsze wynosi „1”. Jeśli model został załadowany, maksymalna wartość suwaka powinna wynosić this._numConstructionSteps. Możemy rozpoznać te wartości za pomocą atrybutów <mwc-slider>. Możemy też użyć directive ifDefined lit-html, aby uniknąć ustawiania atrybutu max, jeśli właściwość _numConstructionSteps nie została zdefiniowana.

Dodaj element <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 są aktywne

Gdy użytkownik przesunie suwak, powinien zmienić się bieżący krok tworzenia, a widoczność modelu powinna zostać odpowiednio zaktualizowana. Suwak będzie wywoływać zdarzenie wejściowe po każdym przeciągnięciu suwaka. Aby przechwycić to zdarzenie i zmienić krok konstrukcyjny, dodaj powiązanie zdarzenia do samego suwaka.

Dodawanie powiązania z wydarzeniem:

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! Suwak pozwala zmienić wyświetlany krok.

Dane „w dół”

Jeszcze jedno. Gdy do zmiany kroku użyjesz przycisków „Wstecz” i „Dalej”, musisz zaktualizować uchwyt suwaka. Powiąż atrybut wartości <mwc-slider> z obiektem 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 prawie skończony. Dodaj styl flex, aby elementy dobrze ze sobą współgrały:

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 w uzyskaniu odwołania 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 z suwakiem (oraz dodatkowe atrybuty pin i markers na suwaku, dzięki czemu wygląda świetnie):

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 efekt końcowy.

Przechodzenie po modelu klocka samochodu za pomocą elementu Brick Viewer

7. Podsumowanie

Dowiedzieliśmy się dużo o tym, jak wykorzystać element lit-element do stworzenia własnego elementu HTML. Nauczyliśmy się, jak:

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

Więcej informacji o elementach świetlnych znajdziesz na oficjalnej stronie.

Ukończony element ceglany znajdziesz na stronie stackblitz.com/edit/brick-viewer-complete.

Narzędzie brick-viewer jest też dostępne w NPM. Źródło znajdziesz tutaj: repozytorium GitHub.