Crea un visor de ladrillos con lit-element

1. Introducción

Componentes web

Los componentes web son un conjunto de estándares web que permiten a los desarrolladores extender el código HTML con elementos personalizados. En este codelab, definirás el elemento <brick-viewer>, que podrá mostrar modelos de ladrillos.

elemento-iluminado

Para ayudarnos a definir nuestro elemento personalizado <brick-viewer>, usaremos lit-element. lit-element es una clase base liviana que agrega algunos elementos sintácticos al estándar de componentes web. Esto nos permitirá ponernos en marcha fácilmente con nuestro elemento personalizado.

Comenzar

Programaremos en un entorno de Stackblitz en línea, así que abre este vínculo en una ventana nueva:

stackblitz.com/edit/brick-viewer

Comencemos.

2. Cómo definir un elemento personalizado

Definición de clase

Para definir un elemento personalizado, crea una clase que extienda LitElement y decóralo con @customElement. El argumento para @customElement será el nombre del elemento personalizado.

En brick-viewer.ts, agrega lo siguiente:

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

Ahora, el elemento <brick-viewer></brick-viewer> está listo para usarse en HTML. Sin embargo, si lo intentas, no se renderizará nada. Sin embargo, podemos solucionarlo.

Método de renderización

Para implementar la vista del componente, define un método llamado render. Este método debe mostrar un literal de plantilla etiquetado con la función html. Coloca el código HTML que quieras en el literal de plantilla etiquetado. Esto se renderizará cuando uses <brick-viewer>.

Agrega el método render:

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

3. Especifica el archivo LDraw

Define una propiedad

Sería genial si un usuario de <brick-viewer> pudiera especificar qué modelo de ladrillo mostrar con un atributo, como el siguiente:

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

Dado que estamos compilando un elemento HTML, podemos aprovechar la API declarativa y definir un atributo de origen, como una etiqueta <img> o <video>. Con lit-element, es tan fácil como decorar una propiedad de clase con @property. La opción type te permite especificar cómo lit-element analiza la propiedad para usarla como atributo HTML.

Define la propiedad y el atributo src:

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

<brick-viewer> ahora tiene un atributo src que podemos configurar en HTML. Su valor ya se puede leer desde nuestra clase BrickViewer gracias a lit-element.

Muestra valores

Podemos mostrar el valor del atributo src usándolo en el literal de plantilla del método de renderización. Interpola valores en literales de plantilla con la sintaxis ${value}.

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

Ahora, vemos el valor del atributo src en el elemento <brick-viewer> en la ventana. Prueba lo siguiente: Abre las herramientas para desarrolladores de tu navegador y cambia manualmente el atributo src. Adelante, pruébala…

…¿Notaste que el texto del elemento se actualiza automáticamente? lit-element observa las propiedades de la clase decoradas con @property y vuelve a renderizar la vista por ti. lit-element hace el trabajo pesado para que no tengas que hacerlo.

4. Configura la escena con Three.js

Luces, cámara, renderización

Nuestro elemento personalizado usará three.js para renderizar nuestros modelos de ladrillos 3D. Hay algunas tareas que queremos hacer solo una vez para cada instancia de un elemento <brick-viewer>, como configurar la escena, la cámara y la iluminación de tres.js. Los agregaremos al constructor de la clase BrickViewer. Conservaremos algunos objetos como propiedades de clase para poder usarlos más adelante: cámara, escena, controles y renderizador.

Agrega la configuración de la escena de 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);
  };
}

El objeto WebGLRenderer proporciona un elemento DOM que muestra la escena de three.js renderizada. Se puede acceder a él a través de la propiedad domElement. Podemos interpolar este valor en el literal de plantilla de renderización, con la sintaxis ${value}.

Quita el mensaje src que teníamos en la plantilla y, luego, inserta el elemento DOM del procesador:

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

Para permitir que el elemento de dominio del procesador se muestre por completo, también debemos establecer el elemento <brick-viewer> en display: block. Podemos proporcionar estilos en una propiedad estática llamada styles, configurada como un literal de plantilla css.

Agrega el siguiente estilo a la clase:

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

Ahora, <brick-viewer> muestra una escena renderizada de Three.js:

Un elemento de visor de ladrillos que muestra una escena renderizada, pero vacía.

Pero… está vacía. Proporciónale un modelo.

Cargador de ladrillos

Pasaremos la propiedad src que definimos antes a LDrawLoader, que se incluye con tres.js.

Los archivos LDraw pueden separar un modelo de ladrillo en pasos de compilación separados. Se puede acceder a la cantidad total de pasos y a la visibilidad de los ladrillos individuales a través de la API de LDrawLoader.

Copia estas propiedades, el nuevo método _loadModel y la línea nueva en el constructor:

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

¿Cuándo se debe llamar a _loadModel? Se debe invocar cada vez que cambia el atributo src. Al decorar la propiedad src con @property, habilitamos la propiedad en el ciclo de vida de actualización de lit-element. Cada vez que cambia el valor de una de estas propiedades decoradas, se llama a una serie de métodos que pueden acceder a los valores nuevos y antiguos de las propiedades. El método de ciclo de vida que nos interesa se llama update. El método update toma un argumento PropertyValues, que contendrá información sobre las propiedades que acaban de cambiar. Este es el lugar perfecto para llamar a _loadModel.

Agrega el método update:

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

Nuestro elemento <brick-viewer> ahora puede mostrar un archivo de ladrillo, especificado con el atributo src.

Un elemento de visor de ladrillos que muestra el modelo de un auto.

5. Visualización de modelos parciales

Ahora, hagamos que el paso de construcción actual sea configurable. Nos gustaría poder especificar <brick-viewer step="5"></brick-viewer> y deberíamos ver cómo se ve el modelo de ladrillo en el quinto paso de construcción. Para ello, decoremos la propiedad step con @property para que sea una propiedad observada.

Decora la propiedad step:

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

Ahora, agregaremos un método de ayuda que solo hará visibles los ladrillos hasta el paso de construcción actual. Llamaremos al asistente en el método de actualización para que se ejecute cada vez que se modifique la propiedad step.

Actualiza el método update y agrega el nuevo 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);
  }
}

Muy bien, ahora abre las Herramientas para desarrolladores de tu navegador y, luego, inspecciona el elemento <brick-viewer>. Agrega un atributo step de la siguiente manera:

Código HTML de un elemento brick-viewer, con un atributo step establecido en 10.

Observa lo que sucede con el modelo renderizado. Podemos usar el atributo step para controlar qué parte del modelo se muestra. A continuación, se muestra cómo debería verse cuando el atributo step se establece en "10":

Un modelo de ladrillo con solo diez escalones de construcción.

6. Navegación del conjunto de ladrillos

mwc-icon-button

El usuario final de nuestro <brick-viewer> también debería poder navegar por los pasos de compilación a través de la IU. Agreguemos botones para ir al paso siguiente, al paso anterior y al primer paso. Para facilitar el proceso, usaremos el componente web de botón de Material Design. Como @material/mwc-icon-button ya se importó, estamos listos para agregar a <mwc-icon-button></mwc-icon-button>. Podemos especificar el ícono que queremos usar con el atributo de ícono de la siguiente manera: <mwc-icon-button icon="thumb_up"></mwc-icon-button>. Puedes encontrar todos los íconos posibles aquí: material.io/resources/icons.

Agreguemos algunos botones de íconos al método de renderización:

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 Material Design en nuestra página es así de fácil, gracias a los componentes web.

Vinculaciones de eventos

Estos botones deberían realizar una acción. El botón “responder” debería restablecer el paso de construcción a 1. El botón "Navigate_before" debe disminuir el paso de construcción y el botón "Navigate_next" debe aumentarlo. lit-element facilita la adición de esta funcionalidad, con vinculaciones de eventos. En tu literal de plantilla HTML, usa la sintaxis @eventname=${eventHandler} como un atributo del elemento. Ahora se ejecutará eventHandler cuando se detecte un evento eventname en ese elemento. A modo de ejemplo, agreguemos controladores de eventos de clic a nuestros tres botones de ícono:

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

Intenta hacer clic en los botones ahora. ¡Buen trabajo!

Estilos

Los botones funcionan, pero no se ven bien. Están todos juntos en la parte inferior. Démosles un estilo para que se superpongan en la escena.

Para aplicar estilos a estos botones, volvemos a la propiedad static styles. Estos estilos tienen alcance, lo que significa que solo se aplicarán a elementos de este componente web. Ese es uno de los beneficios de escribir componentes web: los selectores pueden ser más simples, y CSS será más fácil de leer y escribir. ¡Adiós, BEM!

Actualiza los estilos para que se vean de la siguiente manera:

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

Un elemento de visor de ladrillos con botones de reinicio, atrás y adelante.

Botón de restablecimiento de la cámara

Los usuarios finales de nuestro <brick-viewer> pueden rotar la escena con los controles del mouse. Mientras agregamos botones, agreguemos uno para restablecer la cámara a su posición predeterminada. Otro <mwc-icon-button> con una vinculación de evento de clic hará el trabajo.

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

Navegación más veloz

Algunos sets de ladrillos tienen muchos pasos. Es posible que un usuario quiera omitir un paso específico. Agregar un control deslizante con los números de los pasos puede ayudar con la navegación rápida. Para ello, usaremos el elemento <mwc-slider>.

deslizador de mwc

El elemento de control deslizante necesita algunos datos importantes, como el valor mínimo y máximo del control deslizante. El valor mínimo del control deslizante siempre puede ser “1”. El valor máximo del control deslizante debe ser this._numConstructionSteps si el modelo se cargó. Podemos indicarle a <mwc-slider> estos valores mediante sus atributos. También podemos usar la directiva lit-html ifDefined para evitar configurar el atributo max si no se definió la propiedad _numConstructionSteps.

Agrega un <mwc-slider> entre los botones “atrás” y “adelante”:

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

Datos "en alza"

Cuando un usuario mueve el control deslizante, el paso de construcción actual debe cambiar, y la visibilidad del modelo debe actualizarse según corresponda. El elemento de control deslizante emitirá un evento de entrada cada vez que se arrastre el control deslizante. Agrega una vinculación de eventos en el propio control deslizante para detectar este evento y cambiar el paso de construcción.

Agrega la vinculación 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>
    `;
  }
}

¡Bravo! Podemos usar el control deslizante para cambiar qué paso se muestra.

Los datos están "caídos"

Hay algo más. Cuando se usan los botones "atrás" y "siguiente" para cambiar el paso, se debe actualizar el control del control deslizante. Vincula el atributo de valor de <mwc-slider> a this.step.

Agrega la vinculación 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>
    `;
  }
}

Ya casi terminamos con el control deslizante. Agrega un estilo flexible para que se muestre bien con los otros controles:

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

Además, debemos llamar a layout en el elemento del control deslizante. Lo haremos en el método de ciclo de vida firstUpdated, al que se llama una vez que se organiza el DOM por primera vez. El decorador query puede ayudarnos a obtener una referencia al elemento del control deslizante en la plantilla.

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

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

Aquí tienes todos los elementos agregados al control deslizante (con atributos pin y markers adicionales en el control deslizante para que se vea bien):

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 es el producto final.

Navegación de un modelo de ladrillo de automóvil con el elemento de visor de ladrillos

7. Conclusión

Aprendimos mucho sobre cómo usar lit-element para compilar nuestro propio elemento HTML. Aprendimos a hacer lo siguiente:

  • Cómo definir un elemento personalizado
  • Cómo declarar una API de atributos
  • Renderiza una vista para un elemento personalizado
  • Encapsula los estilos
  • Usa eventos y propiedades para pasar datos

Si quieres obtener más información acerca de lit-element, puedes leer el artículo en su sitio oficial.

Puedes ver un elemento brick-viewer completo en stackblitz.com/edit/brick-viewer-complete.

brick-viewer también se envía en NPM, y puedes ver la fuente aquí: repositorio de GitHub.