Criar um visualizador de tijolos com o elemento lit

1. Introdução

Componentes da Web

Os componentes da Web são uma coleção de padrões da Web que permitem que os desenvolvedores estendam o HTML com elementos personalizados. Neste codelab, você definirá o elemento <brick-viewer>, que poderá mostrar modelos de peças.

Elemento aceso

Para nos ajudar a definir nosso elemento personalizado <brick-viewer>, usaremos o elemento lit. lit-element é uma classe de base leve que adiciona uma leveza sintática ao padrão de componentes da Web. Isso facilitará o funcionamento do nosso elemento personalizado.

Começar

Codificaremos em um ambiente Stackblitz on-line, então 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 decore-a com @customElement. 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. Porém, 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 marcado com a função html. Coloque o HTML desejado no literal de modelo 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. Especificação do arquivo LDraw

Definir uma propriedade

Seria ótimo se um usuário de <brick-viewer> pudesse especificar qual modelo de tijolos a ser exibido usando um atributo, como este:

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

Como estamos criando um elemento HTML, podemos usar a API declarativa e definir um atributo de origem, assim como uma tag <img> ou <video>. Com o elemento lit, é tão fácil quanto decorar uma propriedade de classe com @property. A opção type permite especificar como o elemento lit 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;
}

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

Mostrar valores

Podemos mostrar o valor do atributo src usando-o no literal do modelo do método de renderização. Interpolar 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> da janela. Tente fazer o seguinte: abra as ferramentas para desenvolvedores do navegador e altere manualmente o atributo src. Vá em frente, tente...

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

4. Defina o cenário com o Three.js

Luz, câmera, renderização!

Nosso elemento personalizado usa três.js para renderizar nossos modelos de peças em 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 adicioná-las ao construtor da classe BrickViewer. Vamos manter alguns objetos como propriedades de classe para podermos usá-los mais tarde: câmera, cena, controles e renderizador.

Adicione a configuração do cenário do 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 exibe a cena três.js renderizada. Ele é acessado pela propriedade domElement. Podemos interpolar esse valor no literal do modelo 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 inteiro, também precisamos definir o próprio 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á exibindo uma cena três.js renderizada:

Um elemento de visualizador de tijolos exibindo uma cena renderizada, mas vazia.

Mas... ela está vazia. Vamos fornecer um modelo.

Carregador de tijolos

Transmitiremos a propriedade src definida anteriormente para o LDrawLoader, que é fornecido com o three.js.

Os arquivos LDraw podem separar um modelo de bloco em etapas de construção distintas. O número total de etapas e a visibilidade individual do bloco são acessíveis por meio da 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? Precisa ser invocado sempre que o atributo src for alterado. Ao decorar a propriedade src com @property, ativamos a propriedade no ciclo de vida de atualização do elemento lit. Sempre que uma dessas propriedades decoradas mudança de valor, uma série de métodos é chamado que pode 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 conterá informações sobre todas 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 exibir um arquivo de tijolos, especificado com o atributo src.

Um elemento de visualização de tijolos exibindo o modelo de um carro.

5. Exibição de modelos parciais

Agora, vamos tornar a etapa de construção atual configurável. Queremos especificar <brick-viewer step="5"></brick-viewer> e ver como ficará o modelo de peça na quinta etapa da construção. Para fazer isso, vamos tornar a propriedade step 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 as peças 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 mudar.

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 o DevTools do seu navegador e inspecione o elemento <brick-viewer>. Adicione um atributo step a ele da seguinte forma:

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 quanto do modelo é mostrado. Confira como ele vai ficar quando o atributo step for definido como "10":

Um modelo de tijolos com apenas dez degraus construídos.

6. Navegação por bloco

botão de ícone-mwc

O usuário final do <brick-viewer> também precisa conseguir navegar pelas etapas de build pela interface. Vamos adicionar botões para ir para a próxima etapa, a etapa anterior e a primeira. Vamos usar o componente da Web de botões do Material Design para facilitar. Como o @material/mwc-icon-button já foi importado, estamos prontos para usar o <mwc-icon-button></mwc-icon-button>. Podemos especificar o ícone que queremos usar com o atributo de ícone, desta forma: <mwc-icon-button icon="thumb_up"></mwc-icon-button>. Todos os ícones possíveis estão disponíveis 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 realmente devem fazer alguma coisa. A "resposta" deve redefinir a etapa de construção para 1. O comando "Navigate_before" deve diminuir a etapa de construção, e o botão "Navigate_next" deve incrementá-la. lit-element facilita a adição dessa funcionalidade, com vinculações de eventos. No literal do seu modelo HTML, use a sintaxe @eventname=${eventHandler} como um atributo do elemento. A eventHandler agora será executada quando um evento eventname for detectado nesse elemento. Como 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>
    `;
  }
}

Experimente clicar nos botões agora. Bom trabalho!

Estilos

Os botões funcionam, mas não têm uma boa aparência. Eles estão todos reunidos na parte de baixo. Vamos estilizá-los para sobrepor na cena.

Para aplicar estilos a esses botões, retornamos à propriedade static styles. Esses estilos têm escopo, o que significa que se aplicarão somente aos elementos dentro desse componente da Web. Essa é uma das vantagens de escrever componentes da Web: os seletores podem ser mais simples e o CSS será 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 visualizador de tijolos com botões para reiniciar, voltar e avançar.

Botão para redefinir a câmera

Os usuários finais do <brick-viewer> podem girar a cena usando controles do mouse. Enquanto adicionamos botões, vamos adicionar um para redefinir a câmera para a posição padrão. Outro <mwc-icon-button> com uma vinculação de evento de clique fará o job.

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 tijolos têm muitos degraus. 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. Para isso, vamos usar o elemento <mwc-slider>.

controle deslizante mwc

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

Adicione uma <mwc-slider> entre o "verso" e "avançar" botões:

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 "em cima"

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

Adicione a vinculação de eventos:

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 alterar a etapa exibida.

Dados "inativos"

Há mais uma coisa. Quando o botão "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 com o controle deslizante. Adicione um estilo flexível para combinar 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 deslizante. Faremos isso no método firstUpdated do ciclo de vida, que é chamado assim que o DOM é definido pela primeira vez. O decorador query pode nos ajudar a acessar uma referência ao elemento 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 de controles deslizantes (com atributos pin e markers extras no controle deslizante para que ele pareça 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>
   `;
 }
}

Este é o produto final.

Como navegar em um modelo de tijolos de carro com o elemento brick-viewer

7. Conclusão

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

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

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

É possível visualizar um elemento brick-viewer completo em stackblitz.com/edit/brick-viewer-complete.

O brick-viewer também é enviado pelo NPM. Veja o código-fonte aqui: repositório do GitHub.