Crea un visor de ladrillos con lit-element

1. Introducción

Componentes web

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

elemento-iluminado

Para ayudarnos a definir nuestro elemento personalizado <brick-viewer>, usaremos lit-element. lit-element es una clase básica ligera que agrega un poco de sintaxis al estándar de los componentes web. Esto nos permitirá ponernos en marcha fácilmente con nuestro elemento personalizado.

Comenzar

Vamos a programar en un entorno de Stackblitz en línea, así que abre este enlace en una nueva ventana:

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 ladrillo-viewer.ts, incluye 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 desees 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. Cómo especificar el archivo LDraw

Cómo definir 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 el modo en que lit-element analiza la propiedad para usarla como un 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> de la ventana. Prueba lo siguiente: Abre las herramientas para desarrolladores de tu navegador y cambia manualmente el atributo src. Adelante, pruébalo...

...¿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 tú no tengas que hacerlo.

4. Configura la escena con Three.js

¡Luces, cámara, renderiza!

Nuestro elemento personalizado utilizará tres.js para renderizar los modelos de ladrillos en 3D. Hay algunas acciones 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. Mantendremos algunos objetos como propiedades de clase para poder usarlos más adelante: cámara, escena, controles y procesador.

Agrega la configuración de la escena de tres.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 tres.js renderizada. Se accede 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 de tres.js renderizada:

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

Pero... está vacía. A continuación, le proporcionamos 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 la visibilidad de ladrillos individuales a través de la API de LDrawLoader.

Copia estas propiedades, el nuevo método _loadModel y la nueva línea 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 debería llamar a _loadModel? Se debe invocar cada vez que cambie 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 una de estas propiedades decoradas cambia el valor, 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, vamos a hacer que la propiedad step sea una propiedad observada; para ello, la decoración con @property.

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 de visor de ladrillos, con un atributo de paso establecido en 10

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

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

6. Navegación del conjunto de ladrillos

botón-del-ícono-de-mwc

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. Usaremos el componente web de botones de Material Design para facilitarlo. 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>. Puede encontrar todos los íconos posibles aquí: material.io/resources/icons.

Agreguemos algunos botones de ícono 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. La “respuesta” debe restablecer el paso de construcción a 1. El campo "Navigate_before" debería disminuir el paso de construcción, y el valor de "Navigate_next" debe aumentarla. lit-element permite agregar fácilmente esta funcionalidad mediante 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. Todos están agrupados 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 placeres 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 para reiniciar, retroceder y adelantar.

Botón para restablecer la cámara

Los usuarios finales de <brick-viewer> pueden rotar la escena con los controles del mouse. Mientras agregamos botones, agregaremos 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 juegos de ladrillos tienen muchos escalones. Un usuario podría querer ir a 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 del 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 el "atrás" y "adelante" botones:

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 "subidos"

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 evento en el 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"

Queda una cosa más. Cuando el botón "Atrás" y "siguiente" se usan para cambiar el paso, se debe actualizar el controlador 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 funcione bien con los demás 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 de firstUpdated, al que se llama una vez que se presenta 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();
    }
  }
}

A continuación, se muestran todas las adiciones del control deslizante (con atributos adicionales pin y markers en el control deslizante para que se vea genial):

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
  • Declara una API de atributo
  • Cómo renderizar una vista para un elemento personalizado
  • Cómo encapsular 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 de visor de ladrillos completo en stackblitz.com/edit/brick-viewer-complete.

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