Criar um visualizador de tijolos com o elemento lit

1. Introdução

Componentes da Web

Os componentes da Web são um conjunto de padrões da Web que permitem aos desenvolvedores estender o HTML com elementos personalizados. Neste codelab, você vai definir o elemento <brick-viewer>, que poderá mostrar modelos de blocos.

lit-element

Para ajudar a definir nosso elemento personalizado <brick-viewer>, vamos usar o lit-element, uma classe de base leve que adiciona um pouco de açúcar sintático ao padrão de componentes da Web. Isso vai facilitar a instalação e o funcionamento do nosso elemento personalizado.

Começar

Vamos programar em um ambiente on-line do Stackblitz. Abra este link em uma nova janela:

stackblitz.com/edit/brick-viewer

Vamos começar!

2. Definir um elemento personalizado

Definição de classe

Para definir um elemento personalizado, crie uma classe que estenda LitElement e adicione a anotação @customElement a ela. O argumento para @customElement será o nome do elemento personalizado.

Em brick-viewer.ts, coloque:

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

Agora, o elemento <brick-viewer></brick-viewer> está pronto para uso em HTML. Mas, se você tentar, nada será renderizado. Vamos corrigir isso.

Método de renderização

Para implementar a visualização do componente, defina um método chamado "render". Esse método precisa retornar um literal de modelo com tag usando a função html. Coloque o HTML que quiser no modelo literal com tag. Isso será renderizado quando você usar <brick-viewer>.

Adicione o método render:

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

3. Como especificar o arquivo LDraw

Definir uma propriedade

Seria ótimo se um usuário do <brick-viewer> pudesse especificar qual modelo de bloco mostrar usando um atributo, assim:

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

Como estamos criando um elemento HTML, podemos aproveitar a API declarativa e definir um atributo de origem, assim como uma tag <img> ou <video>. Com o lit-element, basta decorar uma propriedade de classe com @property. A opção type permite especificar como o lit-element analisa a propriedade para uso como um atributo HTML.

Defina a propriedade e o atributo src:

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

Agora, <brick-viewer> tem um atributo src que pode ser definido em HTML. O valor já pode ser lido na classe BrickViewer graças ao lit-element.

Como mostrar valores

Podemos mostrar o valor do atributo src usando-o no modelo literal do método de renderização. Interpole valores em literais de modelo usando a sintaxe ${value}.

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

Agora, vemos o valor do atributo src no elemento <brick-viewer> na janela. Tente o seguinte: abra as ferramentas para desenvolvedores do navegador e mude manualmente o atributo src. Faça um teste...

...Você notou que o texto no elemento é atualizado automaticamente? O lit-element observa as propriedades de classe decoradas com @property e renderiza novamente a visualização para você. O lit-element faz o trabalho pesado para que você não precise fazer isso.

4. Definir a cena com Three.js

Luz, câmera, renderização!

Nosso elemento personalizado vai usar o three.js para renderizar os modelos de blocos 3D. Há algumas coisas que queremos fazer apenas uma vez para cada instância de um elemento <brick-viewer>, como configurar a cena, a câmera e a iluminação do three.js. Vamos adicionar esses elementos ao construtor da classe BrickViewer. Vamos manter alguns objetos como propriedades de classe para que possamos usá-los mais tarde: câmera, cena, controles e renderizador.

Adicione a configuração da cena 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);
  };
}

O objeto WebGLRenderer fornece um elemento DOM que mostra a cena renderizada do three.js. Ele é acessado pela propriedade domElement. Podemos interpolar esse valor no modelo literal de renderização usando a sintaxe ${value}.

Remova a mensagem src que tínhamos no modelo e insira o elemento DOM do renderizador:

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

Para permitir que o elemento DOM do renderizador seja mostrado por completo, também precisamos definir o elemento <brick-viewer> como display: block. Podemos fornecer estilos em uma propriedade estática chamada styles, definida como um literal de modelo css.

Adicione este estilo à classe:

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

Agora, <brick-viewer> está mostrando uma cena renderizada do three.js:

Um elemento brick-viewer mostrando uma cena renderizada, mas vazia.

Mas… está vazio. Vamos fornecer um modelo.

Carregador de tijolos

Vamos transmitir a propriedade src que definimos anteriormente para o LDrawLoader, que é enviado com o three.js.

Os arquivos LDraw podem separar um modelo de bloco em etapas de construção separadas. O número total de etapas e a visibilidade de cada peça são acessíveis pela API LDrawLoader.

Copie essas propriedades, o novo método _loadModel e a nova linha no construtor:

@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 _loadModel deve ser chamado? Ele precisa ser invocado sempre que o atributo src muda. Ao decorar a propriedade src com @property, ativamos o ciclo de vida de atualização do lit-element. Sempre que o valor de uma dessas propriedades decoradas muda, uma série de métodos é chamada para acessar os valores novos e antigos das propriedades. O método do ciclo de vida em que estamos interessados é chamado de update. O método update usa um argumento PropertyValues, que contém informações sobre as propriedades que acabaram de mudar. Este é o lugar perfeito para chamar _loadModel.

Adicione o método update:

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

Nosso elemento <brick-viewer> agora pode mostrar um arquivo de bloco, especificado com o atributo src.

Um elemento brick-viewer mostrando um modelo de carro.

5. Mostrar modelos parciais

Agora, vamos tornar a etapa de construção atual configurável. Queremos especificar <brick-viewer step="5"></brick-viewer> e ver como o modelo de tijolo aparece na quinta etapa de construção. Para isso, vamos transformar a propriedade step em uma propriedade observada decorando-a com @property.

Decore a propriedade step:

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

Agora, vamos adicionar um método auxiliar que torna visíveis apenas os blocos até a etapa de build atual. Vamos chamar o auxiliar no método de atualização para que ele seja executado sempre que a propriedade step for alterada.

Atualize o método update e adicione o novo método _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);
  }
}

Agora abra as ferramentas de desenvolvimento do navegador e inspecione o elemento <brick-viewer>. Adicione um atributo step a ele, assim:

Código HTML de um elemento brick-viewer, com um atributo de etapa definido como 10.

Veja o que acontece com o modelo renderizado. Podemos usar o atributo step para controlar a quantidade do modelo que é mostrada. Veja como fica quando o atributo step é definido como "10":

Um modelo de tijolo com apenas dez etapas de construção.

6. Navegação por conjunto de blocos

mwc-icon-button

O usuário final do nosso <brick-viewer> também precisa conseguir navegar pelas etapas de build na interface. Vamos adicionar botões para ir para a próxima etapa, a etapa anterior e a primeira etapa. Vamos usar o componente da Web de botão do Material Design para facilitar. Como @material/mwc-icon-button já foi importado, podemos inserir <mwc-icon-button></mwc-icon-button>. Podemos especificar o ícone que queremos usar com o atributo "icon", assim: <mwc-icon-button icon="thumb_up"></mwc-icon-button>. Todos os ícones possíveis podem ser encontrados aqui: material.io/resources/icons.

Vamos adicionar alguns botões de ícone ao método de renderização:

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

Usar o Material Design na nossa página é muito fácil graças aos componentes da Web.

Vinculações de eventos

Esses botões precisam fazer algo. O botão "Responder" precisa redefinir a etapa de construção para 1. O botão "navigate_before" precisa diminuir a etapa de construção, e o botão "navigate_next" precisa aumentar. O lit-element facilita a adição dessa funcionalidade com vinculações de eventos. No literal de modelo HTML, use a sintaxe @eventname=${eventHandler} como um atributo de elemento. Agora, o eventHandler será executado quando um evento eventname for detectado nesse elemento. Por exemplo, vamos adicionar manipuladores de eventos de clique aos nossos três botões de ícone:

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

Clique nos botões agora. Bom trabalho!

Estilos

Os botões funcionam, mas não têm uma boa aparência. Elas estão todas amontoadas na parte de baixo. Vamos estilizar para sobrepor à cena.

Para aplicar estilos a esses botões, vamos voltar à propriedade static styles. Esses estilos são definidos no escopo, o que significa que eles só se aplicam a elementos dentro desse componente da Web. Essa é uma das vantagens de escrever componentes da Web: os seletores podem ser mais simples, e o CSS fica mais fácil de ler e escrever. Tchau, BEM!

Atualize os estilos para que fiquem assim:

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

Um elemento de visualização de blocos com botões de reinício, voltar e avançar.

Botão de redefinição da câmera

Os usuários finais do nosso <brick-viewer> podem girar a cena usando os controles do mouse. Já que estamos adicionando botões, vamos incluir um para redefinir a câmera para a posição padrão. Outro <mwc-icon-button> com uma vinculação de evento de clique vai resolver o problema.

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

Navegação mais rápida

Alguns conjuntos de blocos têm muitas etapas. Um usuário pode querer pular para uma etapa específica. Adicionar um controle deslizante com números de etapas pode ajudar na navegação rápida. Vamos usar o elemento <mwc-slider> para isso.

mwc-slider

O elemento de controle deslizante precisa de alguns dados importantes, como o valor mínimo e máximo. O valor mínimo do controle deslizante pode ser sempre "1". O valor máximo do controle deslizante deve ser this._numConstructionSteps, se o modelo tiver sido carregado. Podemos informar esses valores ao <mwc-slider> usando os atributos dele. Também podemos usar a diretiva ifDefined do lit-html para evitar definir o atributo max se a propriedade _numConstructionSteps não tiver sido definida.

Adicione um <mwc-slider> entre os botões "Voltar" e "Avançar":

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

Dados "para cima"

Quando um usuário move o controle deslizante, a etapa de construção atual muda, e a visibilidade do modelo é atualizada de acordo. O elemento slider vai emitir um evento de entrada sempre que o slider for arrastado. Adicione uma vinculação de evento ao controle deslizante para capturar esse evento e mudar a etapa de construção.

Adicione a vinculação de evento:

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

Uhu! Podemos usar o controle deslizante para mudar a etapa exibida.

Dados "inativos"

Tem mais uma coisa. Quando os botões "voltar" e "próximo" são usados para mudar a etapa, a alça do controle deslizante precisa ser atualizada. Vincule o atributo de valor de <mwc-slider> a this.step.

Adicione a vinculação 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>
    `;
  }
}

Estamos quase terminando o controle deslizante. Adicione um estilo flexível para que ele funcione bem com os outros controles:

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

Além disso, precisamos chamar layout no próprio elemento do controle deslizante. Vamos fazer isso no método do ciclo de vida firstUpdated, que é chamado quando o DOM é apresentado pela primeira vez. O decorador query pode ajudar a gerar uma referência ao elemento de controle deslizante no modelo.

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

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

Confira todas as adições do controle deslizante juntas (com atributos pin e markers extras para deixar o controle deslizante mais legal):

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

Este é o produto final.

Navegar em um modelo de bloco de carro com o elemento brick-viewer

7. Conclusão

Aprendemos muito sobre como usar o lit-element para criar nosso próprio elemento HTML. Aprendemos a:

  • Definir um elemento personalizado
  • Declarar uma API de atributo
  • Renderizar uma visualização para um elemento personalizado
  • Encapsular estilos
  • Usar eventos e propriedades para transmitir dados

Se quiser saber mais sobre o lit-element, acesse o site oficial (em inglês).

Você pode conferir um elemento brick-viewer concluído em stackblitz.com/edit/brick-viewer-complete.

O brick-viewer também é enviado no NPM. Confira a origem aqui: repositório do GitHub.