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 aos desenvolvedores ampliar 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 o elemento personalizado <brick-viewer>, vamos usar o elemento lit, que é uma classe de base leve que adiciona mais elementos sintáticos ao padrão dos 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 no 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 com a tag da 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. Como especificar o 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 aproveitar 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;
}

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

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, teste...

Você notou 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ê. O 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 o three.js para renderizar nossos modelos de peças em 3D. Há algumas coisas que queremos fazer apenas uma vez em 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 métodos ao construtor da classe BrickViewer. Vamos manter alguns objetos como propriedades de classe para poder 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 do visualizador de tijolos que mostra 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 precisa ser chamado? Ele precisa ser invocado sempre que o atributo src mudar. Ao decorar a propriedade src com @property, ativamos a propriedade no ciclo de vida de atualização do elemento lit. 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 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 um modelo de 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 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 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 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 o devtools do 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 a quantidade do modelo que será mostrada. Veja como ele ficará quando o atributo step for definido como "10":

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

6. Navegação no conjunto de tijolos

mwc-icon-button

O usuário final do <brick-viewer> também precisa poder navegar pelas etapas de build pela interface. Vamos adicionar botões para ir para a próxima etapa, a etapa anterior e a primeira. Usaremos o componente de botão da Web do Material Design para facilitar as coisas. 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", como este: <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 precisam fazer algo. O botão "responder" redefinirá a etapa de criação para 1. O botão "navigate_before" precisa diminuir a etapa de construção, e o botão "navigate_next" precisa aumentá-la. O 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. eventHandler vai ser executado quando um evento eventname for detectado nesse elemento. Como exemplo, vamos adicionar manipuladores de eventos de clique aos 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 amontoados na parte de baixo. Vamos estilizá-los para sobrepor na cena.

Para aplicar estilos a esses botões, voltamos à propriedade static styles. Esses estilos têm escopo, o que significa que eles só se aplicam a elementos dentro desse componente da Web. Essa é uma das vantagens de criar 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 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 os 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. O 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>.

mwc-slider

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 ao <mwc-slider> pelos atributos dele. Também podemos usar a diretiva ifDefined lit-html para evitar a definição do 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 "ativos"

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 vai emitir um evento de entrada sempre que 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 "para baixo"

Há mais uma coisa. Quando os botões "voltar" e "avançar" são usados para mudar a etapa, o identificador do controle deslizante precisa ser atualizado. 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>
    `;
  }
}

O controle deslizante está quase pronto. 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 é disposto pela primeira vez. O decorador query pode nos ajudar a conseguir uma referência ao elemento do 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 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>
   `;
 }
}

Confira 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 para um elemento personalizado
  • Encapsular estilos
  • Usar eventos e propriedades para transmitir dados

Para saber mais sobre o lit-element, acesse o site oficial.

Confira um elemento brick-viewer completo em stackblitz.com/edit/brick-viewer-complete.

O brick-viewer também é enviado no NPM, e você pode conferir a origem aqui: repositório do GitHub.