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

1. Wprowadzenie

Komponenty internetowe

Komponenty sieciowe to zbiór standardów internetowych, które pozwalają programistom rozszerzać kod HTML o elementy niestandardowe. W ramach tego ćwiczenia w Codelabs zdefiniujesz element <brick-viewer>, który będzie mógł wyświetlać modele ceglane.

element świetlny

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

Rozpocznij

Będziemy kodować 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 zakres LitElement, i dekoruj ją za pomocą @customElement. Argument @customElement będzie nazwą elementu niestandardowego.

W pliku brick-viewer.ts wpisz:

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

Możesz już użyć elementu <brick-viewer></brick-viewer> 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. Umieść dowolny kod HTML w odpowiednim literaale szablonu. Obraz zostanie wyrenderowany, 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

Zdefiniuj usługę

Najlepiej, gdyby użytkownik <brick-viewer> mógł określić, który model klocków ma być wyświetlany, 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 z poziomu klasy BrickViewer dzięki elementowi świetlnemu.

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>`;
  }
}

Teraz widzimy wartość atrybutu src w elemencie <brick-viewer> w oknie. 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ś, że tekst w elemencie jest aktualizowany automatycznie? lit-element obserwuje właściwości klasy ozdobione znakiem @property i ponownie renderuje widok. i wykonuje ciężką pracę za Ciebie.

4. Twórz otoczenie za pomocą Three.js

Światła, kamera, renderowanie!

Nasz element niestandardowy użyje pliku trzy.js do renderowania klocków 3D. W niektórych przypadkach musimy 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 uzyskuje się przez usługę domElement. Możemy interpolować tę wartość do literału szablonu renderowania przy użyciu 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 element DOM mechanizmu renderowania był wyświetlany w całości, trzeba również ustawić wartość display: block w samym elemencie <brick-viewer>. Możemy dostarczyć style w właściwości statycznej o nazwie styles ustawionej na literał szablonu css.

Dodaj ten styl do klasy:

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:

Ceglana grafika przedstawiająca wyrenderowaną, ale pustą scenę.

Ale jest pusta. Przedstawmy mu model.

Ładowarka cegła

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 zadzwonić pod numer _loadModel? Musi być wywoływany za każdym razem, gdy zmienia się atrybut src. Dekorując właściwość src za pomocą parametru @property, zdecydowaliśmy się włączyć ją do cyklu aktualizacji elementu świetlnego. Gdy któreś z tych udekorowanych posiadłości zmian wartości, wywoływany jest szereg 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 wszelkich 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

Teraz skonfigurujmy bieżący etap tworzenia. Chcemy określić <brick-viewer step="5"></brick-viewer>, co pozwoli nam zobaczyć, jak będzie wyglądać model z cegły na piątym kroku 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ą, dzięki której widoczne będą tylko klocki aż do bieżącego etapu kompilacji. Będziemy wywołać pomocnik w metodzie aktualizacji, tak aby uruchamiał się po każdej zmianie właściwości 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 deweloperskie 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 dzieje się z wyrenderowanym modelem. Możemy użyć atrybutu step, aby kontrolować, jaka część modelu ma być wyświetlana. Gdy atrybut step ma wartość "10", powinno to wyglądać tak:

Ceglany model z zaledwie 10 etapami konstrukcyjnymi.

6. Nawigacja po zestawie klocków

Ikona-przycisk-mwc

Użytkownik <brick-viewer> powinien też być w stanie poruszać się po kolejnych krokach 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. Domena @material/mwc-icon-button została już zaimportowana, więc możemy przejść do <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 zastosowanie na naszej stronie stylu Material Design jest naprawdę proste.

Powiązania zdarzeń

Te przyciski powinny właściwie działać. Opcja „Odpowiedz” powinien zresetować krok tworzenia do 1. Parametr „Navigation_before” powinien zmniejszyć liczbę kroków do wykonania, a parametr „Navigation_next”, powinien ją zwiększyć. lit-element ułatwia dodanie tej funkcji wraz z powiązaniami zdarzeń. W literalu szablonu HTML użyj składni @eventname=${eventHandler} jako atrybutu elementu. eventHandler będzie się teraz uruchamiać, gdy w 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 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 właściwości static styles. Style te mają ograniczony zakres, co oznacza, że będą stosowane tylko do elementów w tym komponencie internetowym. To jedna z największych zalet tworzenia komponentów sieciowych: selektory mogą być prostsze, a CSS – czytanie i pisanie. 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;
    }
  `;
}

Ceglany element podglądu 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. Kolejny <mwc-icon-button> z powiązaniem zdarzenia kliknięcia wystarczy, by wykonać 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 klocki mają wiele schodków. Użytkownik może pominąć dany krok. Dodanie suwaka z numerami kroków może przyspieszyć nawigację. Wykorzystamy do tego element <mwc-slider>.

mwc-slider

Suwak wymaga kilku ważnych danych, np. minimalnej i maksymalnej wartości suwaka. Minimalna wartość suwaka zawsze wynosi „1”. Maksymalna wartość suwaka powinna wynosić this._numConstructionSteps, jeśli model został wczytany. Możemy rozpoznać te wartości za pomocą atrybutów <mwc-slider>. Możemy też użyć dyrektywy ifDefined lit-html, aby uniknąć ustawiania atrybutu max, jeśli właściwość _numConstructionSteps nie została zdefiniowana.

Dodaj <mwc-slider> między „wstecz” i „dalej” Przyciski:

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, bieżący etap tworzenia powinien się zmienić, a widoczność modelu zostanie odpowiednio zaktualizowana. Suwak będzie wywoływać zdarzenie wejściowe po każdym przeciągnięciu suwaka. Dodaj powiązanie zdarzenia na samym suwaku, aby wychwytywać to zdarzenie i zmienić krok tworzenia.

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

Dane nie działają

Jeszcze jedno. Jeśli przycisk „Wstecz” i „Dalej” służą do zmiany kroku. Uchwyt suwaka trzeba zaktualizować. 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 elastyczny, aby lepiej współgrał z innymi elementami sterującymi:

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

Musisz też wywołać funkcję layout w samym elemencie suwaka. Zrobimy to w metodzie cyklu życia firstUpdated, która jest wywoływana zaraz po skonfigurowaniu DOM. Dekorator query może pomóc nam znaleźć odwołanie 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.

Nawigacja po modelu z cegły samochodu z użyciem elementu przypominającego cegłę

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 interfejsu API atrybutów
  • Renderowanie widoku elementu niestandardowego
  • Uzupełnij style
  • Używanie zdarzeń i właściwości do przekazywania danych

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

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

Usługa Brick-viewer jest też wysyłana w ramach NPM. Źródło możesz zobaczyć tutaj: repozytorium GitHub.