Crea un visualizzatore di mattoncini con lit-element

1. Introduzione

Componenti web

I componenti web sono una raccolta di standard web che consentono agli sviluppatori di estendere l'HTML con elementi personalizzati. In questo codelab definirai l'elemento <brick-viewer>, che sarà in grado di visualizzare i modelli di mattoni.

lit-element

Per definire il nostro elemento personalizzato <brick-viewer>, utilizzeremo lit-element. lit-element è una classe base leggera che aggiunge un po' di zucchero sintattico allo standard dei componenti web. In questo modo, sarà facile iniziare a utilizzare il nostro elemento personalizzato.

Inizia

Scriveremo il codice in un ambiente Stackblitz online, quindi apri questo link in una nuova finestra:

stackblitz.com/edit/brick-viewer

Iniziamo.

2. Definisci un elemento personalizzato

Definizione della classe

Per definire un elemento personalizzato, crea una classe che estenda LitElement e decorala con @customElement. L'argomento di @customElement sarà il nome dell'elemento personalizzato.

In brick-viewer.ts, inserisci:

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

Ora l'elemento <brick-viewer></brick-viewer> è pronto per essere utilizzato in HTML. Tuttavia, se provi a utilizzarlo, non verrà eseguito il rendering di nulla. Risolviamo il problema.

Metodo di rendering

Per implementare la visualizzazione del componente, definisci un metodo denominato render. Questo metodo deve restituire un letterale di modello con tag con la funzione html. Inserisci il codice HTML che vuoi nel letterale di modello con tag. Verrà eseguito il rendering quando utilizzi <brick-viewer>.

Aggiungi il metodo render:

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

3. Specifica il file LDraw

Definisci una proprietà

Sarebbe fantastico se un utente di <brick-viewer> potesse specificare quale modello di mattoni visualizzare utilizzando un attributo, ad esempio:

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

Poiché stiamo creando un elemento HTML, possiamo sfruttare l'API dichiarativa e definire un attributo di origine, proprio come un tag <img> o <video>. Con lit-element, è facile come decorare una proprietà della classe con @property. L'opzione type consente di specificare in che modo lit-element analizza la proprietà per l'utilizzo come attributo HTML.

Definisci la proprietà e l'attributo src:

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

<brick-viewer> ora ha un attributo src che possiamo impostare in HTML. Il suo valore è già leggibile dalla nostra classe BrickViewer grazie a lit-element.

Visualizzazione dei valori

Possiamo visualizzare il valore dell'attributo src utilizzandolo nel letterale di modello del metodo di rendering. Esegui l'interpolazione dei valori nei letterali di modello utilizzando la sintassi ${value}.

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

Ora vediamo il valore dell'attributo src nell'elemento <brick-viewer> nella finestra. Prova a fare quanto segue: apri gli strumenti per sviluppatori del browser e modifica manualmente l'attributo src. Prova a farlo...

...Hai notato che il testo nell'elemento si aggiorna automaticamente? lit-element osserva le proprietà della classe decorate con @property ed esegue nuovamente il rendering della visualizzazione. lit-element si occupa del lavoro pesante, quindi non devi farlo tu.

4. Imposta la scena con Three.js

Luci, camera, rendering!

Il nostro elemento personalizzato utilizzerà three.js per eseguire il rendering dei nostri modelli di mattoni 3D. Ci sono alcune operazioni che vogliamo eseguire una sola volta per ogni istanza di un elemento <brick-viewer>, ad esempio configurare la scena, la fotocamera e l'illuminazione di three.js. Li aggiungeremo al costruttore della classe BrickViewer. Manterremo alcuni oggetti come proprietà della classe in modo da poterli utilizzare in un secondo momento: fotocamera, scena, controlli e renderer.

Aggiungi la configurazione della scena 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);
  };
}

L'oggetto WebGLRenderer fornisce un elemento DOM che visualizza la scena three.js di cui è stato eseguito il rendering. È accessibile tramite la proprietà domElement. Possiamo interpolare questo valore nel letterale del modello di rendering utilizzando la sintassi ${value}.

Rimuovi il messaggio src che avevamo nel modello e inserisci l'elemento DOM del renderer:

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

Per consentire la visualizzazione completa dell'elemento DOM del renderer, dobbiamo anche impostare l'elemento <brick-viewer> stesso su display: block. Possiamo fornire stili in una proprietà statica denominata styles, impostata su un letterale di modello css.

Aggiungi questo stile alla classe:

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

Ora <brick-viewer> mostra una scena three.js di cui è stato eseguito il rendering:

Un elemento visualizzatore di mattoni che mostra una scena sottoposta a rendering, ma vuota.

Ma... è vuoto. Forniamogli un modello.

Caricatore di mattoni

Passeremo la proprietà src definita in precedenza a LDrawLoader, fornito con three.js.

I file LDraw possono separare un modello di mattoni in passaggi di costruzione separati. Il numero totale di passaggi e la visibilità dei singoli mattoni sono accessibili tramite l'API LDrawLoader.

Copia queste proprietà, il nuovo metodo _loadModel e la nuova riga nel costruttore:

@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();
        });
  }
}

Quando deve essere chiamato _loadModel? Deve essere richiamato ogni volta che l'attributo src cambia. Decorando la proprietà src con @property, abbiamo attivato la proprietà nel ciclo di vita degli aggiornamenti di lit-element. Ogni volta che il valore di una di queste proprietà decorate cambia, viene chiamata una serie di metodi che possono accedere ai valori nuovi e precedenti delle proprietà. Il metodo del ciclo di vita che ci interessa si chiama update. Il metodo update accetta un argomento PropertyValues, che conterrà informazioni su tutte le proprietà appena modificate. Questo è il posto perfetto per chiamare _loadModel.

Aggiungi il metodo update:

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

Il nostro elemento <brick-viewer> ora può visualizzare un file di mattoni, specificato con l'attributo src.

Un elemento di visualizzazione dei mattoncini che mostra un modello di auto.

5. Visualizzazione di modelli parziali

Ora rendiamo configurabile il passaggio di costruzione corrente. Vorremmo poter specificare <brick-viewer step="5"></brick-viewer> e dovremmo vedere l'aspetto del modello di mattoni al quinto passaggio di costruzione. Per farlo, rendiamo la proprietà step una proprietà osservata decorandola con @property.

Decora la proprietà step:

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

Ora aggiungeremo un metodo helper che rende visibili solo i mattoni fino al passaggio di costruzione corrente. Chiameremo l'helper nel metodo di aggiornamento in modo che venga eseguito ogni volta che la proprietà step viene modificata.

Aggiorna il metodo update e aggiungi il nuovo metodo _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);
  }
}

Ok, ora apri gli strumenti per sviluppatori del browser ed esamina l'elemento <brick-viewer>. Aggiungi un attributo step, ad esempio:

Codice HTML di un elemento visualizzatore di mattoni, con un attributo step impostato su 10.

Guarda cosa succede al modello di cui è stato eseguito il rendering. Possiamo utilizzare l'attributo step per controllare la quantità di modello visualizzata. Ecco come dovrebbe apparire quando l'attributo step è impostato su "10":

Un modello di mattoncini con solo dieci passaggi di costruzione.

6. Navigazione nel set di mattoni

mwc-icon-button

L'utente finale del nostro <brick-viewer> dovrebbe anche essere in grado di navigare nei passaggi di costruzione tramite l'interfaccia utente. Aggiungiamo i pulsanti per passare al passaggio successivo, al passaggio precedente e al primo passaggio. Per semplificare la procedura, utilizzeremo il componente web del pulsante Material Design. Poiché @material/mwc-icon-button è già importato, possiamo inserire <mwc-icon-button></mwc-icon-button>. Possiamo specificare l'icona che vogliamo utilizzare con l'attributo icon, ad esempio: <mwc-icon-button icon="thumb_up"></mwc-icon-button>. Tutte le icone possibili sono disponibili qui: material.io/resources/icons.

Aggiungiamo alcuni pulsanti icona al metodo di rendering:

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

Grazie ai componenti web, l'utilizzo di Material Design nella nostra pagina è semplicissimo.

Associazioni di eventi

Questi pulsanti dovrebbero effettivamente fare qualcosa. Il pulsante "reply" deve reimpostare il passaggio di costruzione su 1. Il pulsante "navigate_before" deve decrementare il passaggio di costruzione, mentre il pulsante "navigate_next" deve incrementarlo. lit-element semplifica l'aggiunta di questa funzionalità con le associazioni di eventi. Nel letterale del modello HTML, utilizza la sintassi @eventname=${eventHandler} come attributo dell'elemento. eventHandler verrà eseguito quando viene rilevato un evento eventname sull'elemento. Ad esempio, aggiungiamo i gestori di eventi di clic ai tre pulsanti icona:

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

Prova a fare clic sui pulsanti. Bene!

Stili

I pulsanti funzionano, ma non hanno un bell'aspetto. Sono tutti raggruppati in basso. Applichiamo uno stile per sovrapporli alla scena.

Per applicare gli stili a questi pulsanti, torniamo alla proprietà static styles. Questi stili sono con ambito, il che significa che verranno applicati solo agli elementi all'interno di questo componente web. Questo è uno dei vantaggi della scrittura di componenti web: i selettori possono essere più semplici e il CSS sarà più facile da leggere e scrivere. Arrivederci, BEM!

Aggiorna gli stili in modo che abbiano questo aspetto:

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

Un elemento di visualizzazione dei mattoncini con pulsanti di riavvio, indietro e avanti.

Pulsante di reimpostazione della fotocamera

Gli utenti finali del nostro <brick-viewer> possono ruotare la scena utilizzando i controlli del mouse. Mentre aggiungiamo i pulsanti, aggiungiamone uno per reimpostare la fotocamera nella posizione predefinita. Un altro <mwc-icon-button> con un'associazione di eventi di clic farà al caso nostro.

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

Navigazione più rapida

Alcuni set di mattoni hanno molti passaggi. Un utente potrebbe voler saltare a un passaggio specifico. L'aggiunta di un cursore con i numeri dei passaggi può essere utile per la navigazione rapida. Per questo utilizzeremo l'elemento <mwc-slider>.

mwc-slider

L'elemento cursore richiede alcuni dati importanti, come il valore minimo e massimo del cursore. Il valore minimo del cursore può essere sempre "1". Il valore massimo del cursore deve essere this._numConstructionSteps, se il modello è stato caricato. Possiamo comunicare <mwc-slider> questi valori tramite i relativi attributi. Possiamo anche utilizzare la direttiva lit-html ifDefined per evitare di impostare l'attributo max se la proprietà _numConstructionSteps non è stata definita.

Aggiungi un <mwc-slider> tra i pulsanti "Indietro" e "Avanti":

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

Dati "in su"

Quando un utente sposta il cursore, il passaggio di costruzione corrente deve cambiare e la visibilità del modello deve essere aggiornata di conseguenza. L'elemento cursore genererà un evento input ogni volta che il cursore viene trascinato. Aggiungi un'associazione di eventi al cursore stesso per intercettare questo evento e modificare il passaggio di costruzione.

Aggiungi l'associazione di eventi:

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

Evviva! Possiamo utilizzare il cursore per modificare il passaggio visualizzato.

Dati "in giù"

C'è ancora una cosa da fare. Quando i pulsanti "Indietro" e "Avanti" vengono utilizzati per modificare il passaggio, è necessario aggiornare la maniglia del cursore. Associa l'attributo value di <mwc-slider> a this.step.

Aggiungi l'associazione 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>
    `;
  }
}

Abbiamo quasi finito con il cursore. Aggiungi uno stile flex per farlo funzionare correttamente con gli altri controlli:

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

Inoltre, dobbiamo chiamare layout sull'elemento cursore stesso. Lo faremo nel metodo del ciclo di vita firstUpdated, che viene chiamato una volta che il DOM viene disposto per la prima volta. Il decoratore query può aiutarci a ottenere un riferimento all'elemento cursore nel modello.

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

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

Ecco tutte le aggiunte del cursore (con gli attributi pin e markers aggiuntivi sul cursore per renderlo più interessante):

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

Ecco il prodotto finale.

Navigare in un modello di mattoncino di un&#39;auto con l&#39;elemento brick-viewer

7. Conclusione

Abbiamo imparato molto su come utilizzare lit-element per creare il nostro elemento HTML. Abbiamo imparato a:

  • Definire un elemento personalizzato
  • Dichiarare un'API degli attributi
  • Eseguire il rendering di una visualizzazione per un elemento personalizzato
  • Incapsulare gli stili
  • Utilizzare eventi e proprietà per passare i dati

Se vuoi saperne di più su lit-element, puoi consultare il suo sito ufficiale.

Puoi visualizzare un elemento brick-viewer completato all'indirizzo stackblitz.com/edit/brick-viewer-complete.

brick-viewer viene fornito anche su NPM e puoi visualizzare l'origine qui: repository GitHub.