Web Component로 만들어진 사용자 정의 Tag에 스타일 지정하기

AI에게 물어봤더니 방법은 3가지랜다. (AI의 답변을 이렇게 기록해 둔 이유는 답변을 얻기 위한 과정이 단순히 몇번의 프롬프트 입력을 통해 얻지 않았기 때문이다.)

첫째, 호스트 요소(:host) 스타일로 흔히 하듯 사용자 정의 Tag에 대해 스타일을 지정하면 된다.

즉, 아래처럼.

/* style.css */
my-counter {
  display: block; /* 핵심! */
  margin: 20px;
  border: 1px solid red;
  width: 300px;}

사실, 처음엔 제대로 작동하지 않았다. 그 이유는 display에 대한 스타일 값을 지정하지 않았기 때문이다. 사용자 정의 Tag의 display 스타일에 대한 기본값은 미지정이다. 이 display를 지정하지 않으면 padding 이든 margin이든 width든 모두 엉망진창으로 계산되어 반영된다. 이 첫번째가 내가 원했던 답안이다. 그런데 AI는 2가지를 더 제시해줬고 내가 원했던 이 것보다 더 나은 방안이라며 제시했다. 정말 더 나은 방안인지 확인은 해보지 않았으나 일단 정리해 둔다.

두번째는 CSS 커스텀 프로퍼티의 사용이다.

즉, CSS 변수를 사용하라는 건데… 이 방식이 가장 권장되는 방식이라고 한다. AI가 알려준 것을 나열하면 다음과 같다.

컴포넌트 제작자가 내부 스타일의 특정 값을 CSS 변수(Custom Property)로 노출시켜주면, 사용자는 외부에서 이 변수 값을 바꿔서 내부 스타일을 변경할 수 있습니다. 이것이 가장 유연하고 권장되는 “스타일링 API” 설계 방식입니다.

컴포넌트 제작자 (MyCounter.js): 내부 스타일에서 CSS 변수를 사용합니다. var(–변수명, 기본값) 형태로 작성합니다.

// src/MyCounter.js
const templateStyleMyCounter = document.createElement('template');
templateStyleMyCounter.innerHTML = /*html*/ `
  
 `;

컴포넌트 사용자 (style.css): 외부 CSS에서 해당 변수의 값을 재정의합니다.

/* style.css */
my-counter {
  /* MyCounter 내부의 버튼 배경색을 초록색으로 변경 */
  --my-counter-button-bg: green;
  
  /* MyCounter 내부의 span 글자색을 보라색으로 변경 */
  --my-counter-span-color: purple;
}

세번째는 ::part 의사 요소를 사용하는 방법이라고 한다.

컴포넌트 내부 요소를 직접적인 대상으로 스타일을 지정할 수 있다. AI가 설명해준 내용 그대로를 언급하면 다음과 같다.

컴포넌트 제작자가 내부의 특정 요소에 part 속성을 부여하여 외부에 노출시키면, 사용자는 ::part() 의사요소를 사용해 해당 부분의 스타일을 직접 지정할 수 있습니다.

컴포넌트 제작자 (MyCounter.js): 스타일을 지정할 수 있도록 하고 싶은 내부 요소에 part 속성을 추가합니다.

// src/MyCounter.js
const templateDOMMyCounter = document.createElement('template');
templateDOMMyCounter.innerHTML = /*html*/ `
  <div>
    <span part="count-display"></span>  
    <button id="incrementButton" part="increment-button">Increment</button> 
  </div>
`;

컴포넌트 사용자 (style.css): ::part() 선택자를 사용하여 해당 파트의 스타일을 지정합니다.

/* style.css */
/* my-counter 내부의 increment-button 파트의 스타일 지정 */
my-counter::part(increment-button) {
  background-color: orange;
  border-radius: 0;
}

/* my-counter 내부의 count-display 파트의 스타일 지정 */
my-counter::part(count-display) {
  font-style: italic;
  color: red;
}

과연 미래의 소프트웨어 개발은 AI로 인해 얼마나 그 수준이 업그레이드 될 것이며 개발 복잡도는 얼마나 올라갈까…… 향상된 수준과 복잡도를 AI 없이 오직 사람만으로 수용할수 있을까?

MD (Markdown) 문법

# 제목
## 제목
### 제목
#### 제목
##### 제목
###### 제목

안녕하세요.
마크다운 문서를 작성하고 있습니다.
줄간행을 했으나 반영되지 않네요.
두번 엔터를 눌러볼까요..

하하하.
하하핫.

이것은 *기울임*입니다.

이것은 **굵은**이지요.

- 첫번째 항목
- 첫번째 항목에 대해서..
- 첫번째 항목에 대해서..
- 첫번째 항목에 대해서
- 두번째 항목
- 세번째 항목

1. 이것은 첫번째
1. 이것은 첫번째 항목에..
2. 이것은 첫번째 항모게...
1. 이것은..
1. 이것은 두번째

[링크](http://www.gisdeveloper.co.kr)입니다.

![이미지](https://upload.wikimedia.org/wikipedia/commons/4/48/Markdown-mark.svg)

코드를 입력해 볼까요.

`console.log('Hello World')`

```js
function test() {
console.log('Hello world!');
}
```

```rust
fn test() {

}
```

```markdown
> 이 문단은 인용된
> 여러 줄
> 중첩된 인용
```

> test
> 안녕하세요
>> 이중중첩
>>> 삼중중첨
>>>> 사중중첩

---
이것은
***
이것도
___
이것은..

|헤더1|헤더2|헤더3|
|:-|-:|:-:|
|*안녕*|**굿**|~~바이~~|
|엘|젤리|쿠|

이것은 \*표\*입니다.

글의 색상을 넣자

안녕하세요.
반가습니다.

THREE.JS 퀵 레퍼런스 코드

Three.js를 이용한 개발 시 개인적으로 빠르게 참조하기 위해 작성한 글입니다.

three.js 기본 프로젝트 생성 (WebGL)

git clone https://github.com/GISDEVCODE/threejs-with-javascript-starter.git .

three.js 기본 프로젝트 생성 (WebGPU)

git clone https://github.com/GISDEVCODE/threejs-webgpu-with-javascript-starter.git .

R3F 기본 프로젝트 생성 (Javascript)

git clone https://github.com/GISDEVCODE/r3f-with-javascript-starter.git .

Shader 기본 프로젝트 생성 (Javascript)

git clone https://github.com/GISDEVCODE/shader-with-threejs-javascript-starter .

그림자 적용에 대한 코드

renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.VSMShadowMap;

const shadowLight = new THREE.DirectionalLight(0xffe79d, 0.7);
shadowLight.position.set(150, 220, 100);
shadowLight.target.position.set(0,0,0);
shadowLight.castShadow = true;
shadowLight.shadow.mapSize.width = 1024*10;
shadowLight.shadow.mapSize.height = 1024*10;
shadowLight.shadow.camera.top = shadowLight.shadow.camera.right = 1000;
shadowLight.shadow.camera.bottom = shadowLight.shadow.camera.left = -1000;
shadowLight.shadow.camera.far = 800;
shadowLight.shadow.radius = 5;
shadowLight.shadow.blurSamples = 5;
shadowLight.shadow.bias = -0.0002;

const cameraHelper = new THREE.CameraHelper(shadowLight.shadow.camera);
this._scene.add(cameraHelper);

island.receiveShadow = true;
island.castShadow = true;

지오메트리의 좌표 수정

const sphereGeom = new THREE.SphereGeometry(6 + Math.floor(Math.random() * 12), 8, 8);
const sphereGeomPosition = sphereGeom.attributes.position;
for (var i = 0; i < sphereGeomPosition.count; i++) {
    sphereGeomPosition.setY(i, sphereGeomPosition.getY(i) + Math.random() * 4 - 2);
    sphereGeomPosition.setX(i, sphereGeomPosition.getX(i) + Math.random() * 3 - 1.5);
    sphereGeomPosition.setZ(i, sphereGeomPosition.getZ(i) + Math.random() * 3 - 1.5);
}

sphereGeom.computeVertexNormals();
sphereGeom.attributes.position.needsUpdate = true;

지오메트리에 사용자 정의 데이터 주입

// 주입
const waves = [];
const waterGeoPositions = waterGeo.attributes.position;
for (let i = 0; i < waterGeoPositions.count; i++) {
    waves[i] = Math.random() * 100;
}
waterGeo.setAttribute("wave", new THREE.Float32BufferAttribute(waves, 1));

// 읽기
const waves = sea.geometry.attributes.wave;

for(let i=0; i<positions.count; i++) {
    const v = waves.getX(i);
}

안개 설정 코드

scene.fog = new THREE.Fog("rgba(54,219,214,1)", 1000, 1400);

OrbitControls 관련 코드

const controls = new OrbitControls(this._camera, this._divContainer);
controls.minPolarAngle = -Math.PI / 2;
controls.maxPolarAngle = Math.PI / 2 + 0.1;
controls.enableZoom = true;
controls.enablePan = false;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.2;

this._controls = controls;

this._controls.update();

Object3D의 MBR 얻기

const board = this._scene.getObjectByName("Board");
const box = new THREE.Box3().setFromObject(board);
console.log(box);

Mesh의 월드좌표에 대한 position 얻기

mesh.updateMatrixWorld();

const worldPos = new THREE.Vector3();
worldPos.setFromMatrixPosition(mesh.matrixWorld);

Faked Shadow

그림자를 위한 매시에 대한 재질 속성 지정이 핵심. 참고로 shadow에 대한 이미지는 투명 이미지가 아님. 즉, 배경색이 하얀색인 이미지임.

const shadow = new THREE.TextureLoader().load( 'models/gltf/ferrari_ao.png' );

const mesh = new THREE.Mesh(
    new THREE.PlaneGeometry( 0.655 * 4, 1.3 * 4 ),
    new THREE.MeshBasicMaterial( {
        map: shadow, 
        blending: THREE.MultiplyBlending, 
        toneMapped: false, 
        transparent: true
    } )
);
mesh.rotation.x = - Math.PI / 2;
mesh.renderOrder = 2;
carModel.add( mesh );

텍스쳐 이미지 품질 올리기

샘플링 횟수를 올리는 것으로 속도는 느려질 수 있으나 품질은 향상됨

texture.anisotropy = renderer.capabilities.getMaxAnisotropy();

async 리소스 로딩

async function init() {
    const rgbeLoader = new RGBELoader().setPath('textures/equirectangular/');
    const gltfLoader = new GLTFLoader().setPath('models/gltf/DamagedHelmet/glTF/');

    const [texture, gltf] = await Promise.all([
        rgbeLoader.loadAsync( 'venice_sunset_1k.hdr' ),
        gltfLoader.loadAsync( 'DamagedHelmet.gltf' ),
    ]);
}

init().catch(function(err) {
    console.error(err);
});

텍스쳐를 Canvas로 후다닥 만들기

const canvas = document.createElement( 'canvas' );
canvas.width = 1;
canvas.height = 32;

const context = canvas.getContext( '2d' );
const gradient = context.createLinearGradient( 0, 0, 0, 32 );
gradient.addColorStop( 0.0, '#ff0000' );
gradient.addColorStop( 0.5, '#00ff00' );
gradient.addColorStop( 1.0, '#0000ff' );
context.fillStyle = gradient;
context.fillRect( 0, 0, 1, 32 );

const sky = new THREE.Mesh(
	new THREE.SphereGeometry( 10 ),
	new THREE.MeshBasicMaterial( { map: new THREE.CanvasTexture( canvas ), side: THREE.BackSide } )
);
scene.add( sky );

GLTF 파일 로딩

import { GLTFLoader } from "../examples/jsm/loaders/GLTFLoader.js"

const loader = new GLTFLoader();
loader.load("./data/ring.glb", gltf => {
    const object = gltf.scene;
    this._scene.add(object);
});    

InstancedMesh

const mesh = new THREE.InstancedMesh(geometry, material, 10000)

const matrix = new THREE.Matrix4()
const dummy = new THREE.Object3D()
 
for(let i = 0; i < 10000; i++) {
  mesh.getMatrixAt(i, matrix)
  matrix.decompose(dummy.position, dummy.rotation, dummy.scale)
  
  dummy.rotation.x = Math.random()
  dummy.rotation.y = Math.random()
  dummy.rotation.z = Math.random()

  dummy.updateMatrix()

  mesh.setMatrixAt(i, dummy.matrix)
  mesh.setColorAt(i, new THREE.Color(Math.random() * 0xffffff)
}

mesh.instanceMatrix.needsUpdate()

Image 기반 광원(IBL)

import { RGBELoader } from 'three/examples/jsm/Addons.js'

...

new RGBELoader().setPath("./").load("pine_attic_2k.hdr", (data) => {
  data.mapping = THREE.EquirectangularReflectionMapping;
  // this.scene.background = data;
  // this.scene.backgroundBlurriness = 0.6;
  this.scene.environment = data;
})

GLTF / GLB 파일 로딩

import { GLTFLoader, OrbitControls } from "three/addons/Addons.js"

...

const loader = new GLTFLoader();
loader.load(
  "fileName.glb",
  (gltf) => {
    this._scene.add( gltf.scene );    
  },
  (xhr) => { console.log( ( xhr.loaded / xhr.total * 100 ) + "% loaded" ); },
  (error) => { console.log( "An error happened" ); }
);

Object3D를 빌보드로 만들기

this._mesh.quaternion.copy(this._camera.quaternion );
// or
this._mesh.rotation.setFromRotationMatrix( this._camera.matrix );

FPS 제한하기

requestAnimationFrame로 렌더링을 수행하면 최대한 많은 프레임을 생성하기 됨. 아래는 원하는 프레임수로 제한하기 위해 다음 코드로 30 프레임 제한입니다.

  _elapsedTime = 0;
  _fps = 1 / 60
  render() {
    const delta = this._clock.getDelta();
    this.update(delta);
    this._elapsedTime += delta;
    
    if (this._elapsedTime >= (this._fps)) {
      this._stats.begin();
      this._renderer.render(this._scene, this._camera);
      this._stats.end();
      this._elapsedTime %= this._fps;
    }

    requestAnimationFrame(this.render.bind(this));
  }

화면 좌표를 월드 좌표로 변환하는 함수

// 스크린 좌표 -> 월드 좌표 변환 함수
screenToWorld(screenX, screenY, ndcZ = 0.5) { // ndcZ=0.5로 설정 (카메라 방향)
  // 1. 화면 픽셀 좌표를 정규화된 디바이스 좌표(NDC)로 변환
  const ndcX = (screenX / this._divContainer.clientWidth) * 2 - 1;
  const ndcY = -(screenY / this._divContainer.clientHeight) * 2 + 1;

  // 2. NDC를 3D 공간의 Ray로 변환
  const vector = new THREE.Vector3(ndcX, ndcY, ndcZ);
  vector.unproject(this._camera);

  // 3. Ray를 따라 월드 좌표를 계산
  return vector;
}

screenToWorldAtZ(screenX, screenY, targetZ = 0) {
  // 1. 화면 픽셀 좌표 -> NDC 변환
  const ndcX = (screenX / this._divContainer.clientWidth) * 2 - 1;
  const ndcY = -(screenY / this._divContainer.clientHeight) * 2 + 1;

  // 2. NDC -> Ray 생성
  const raycaster = new THREE.Raycaster();
  const vector = new THREE.Vector2(ndcX, ndcY);
  raycaster.setFromCamera(vector, this._camera);

  // 3. Ray와 z=targetZ 평면의 교차점 계산
  const direction = raycaster.ray.direction;
  const origin = raycaster.ray.origin;
  const t = (targetZ - origin.z) / direction.z;

  const worldPoint = origin.clone().add(direction.multiplyScalar(t));
  return worldPoint;
}

모델을 화면 중심에 표시하기

_zoomFix(model, distanceFactor = 1.5) {
  const box = new THREE.Box3().setFromObject(model);
  const center = box.getCenter(new THREE.Vector3());

  model.position.sub(center);

  const size = box.getSize(new THREE.Vector3());
  const maxDimension = Math.max(size.x, size.y, size.z);
  const cameraDistance = maxDimension * distanceFactor;
  this._camera.position.set(0, 0, cameraDistance);
  this._camera.lookAt(this._scene.position);
}

모델의 특정 페이스에 대한 법선 벡터 구하기

_getNormal(mesh, faceIndex) {
  const normalAttribute = mesh.geometry.getAttribute("normal");
  const index = mesh.geometry.index;
  const faceStartIndex = faceIndex * 3;

  const vertexIndexA = index.getX(faceStartIndex);
  const vertexIndexB = index.getX(faceStartIndex + 1);
  const vertexIndexC = index.getX(faceStartIndex + 2);

  const normalA = new THREE.Vector3();
  const normalB = new THREE.Vector3();
  const normalC = new THREE.Vector3();
  const faceNormal = new THREE.Vector3();

  normalA.fromBufferAttribute(normalAttribute, vertexIndexA);
  normalB.fromBufferAttribute(normalAttribute, vertexIndexB);
  normalC.fromBufferAttribute(normalAttribute, vertexIndexC);

  faceNormal.addVectors(normalA, normalB).add(normalC).divideScalar(3).normalize();

  return faceNormal;
}

어떤 위치에서 매시에 가장 가까운 매시 표면의 위치를 얻는 코드

아래 함수의 point 파라메터는 어떤 위치이고 mesh 파라메터가 대상 메시입니다. point에서 가장 가까운 매시 표면의 좌표가 변환됩니다.

findClosestPointOnMesh(/* Vector3 */ point, /* Mesh */ mesh) {
  const geometry = mesh.geometry;
  const vertices = geometry.attributes.position;
  const indices = geometry.index ? geometry.index.array : null;

  let closestPoint = new THREE.Vector3();
  let minDistance = Infinity;

  const localPoint = mesh.worldToLocal(point);

  if (indices) {
    for (let i = 0; i < indices.length; i += 3) {
      const a = new THREE.Vector3().fromBufferAttribute(vertices, indices[i]);
      const b = new THREE.Vector3().fromBufferAttribute(vertices, indices[i + 1]);
      const c = new THREE.Vector3().fromBufferAttribute(vertices, indices[i + 2]);

      const tempPoint = new THREE.Vector3();
      const triangle = new THREE.Triangle(a, b, c);
      triangle.closestPointToPoint(localPoint, tempPoint);

      const distance = localPoint.distanceTo(tempPoint);
      if (distance < minDistance) {
        minDistance = distance;
        closestPoint.copy(tempPoint);
      }
    }
  } else {
    for (let i = 0; i < vertices.count; i += 3) {
      const a = new THREE.Vector3().fromBufferAttribute(vertices, i);
      const b = new THREE.Vector3().fromBufferAttribute(vertices, i + 1);
      const c = new THREE.Vector3().fromBufferAttribute(vertices, i + 2);

      const tempPoint = new THREE.Vector3();
      const triangle = new THREE.Triangle(a, b, c);
      triangle.closestPointToPoint(localPoint, tempPoint);

      const distance = localPoint.distanceTo(tempPoint);
      if (distance < minDistance) {
        minDistance = distance;
        closestPoint.copy(tempPoint);
      }
    }
  }

  return mesh.localToWorld(closestPoint);
}

아래는 위의 함수가 적용된 실행 화면인데, 노란색 포인트 위치가 임의의 위치(point 파라메터)이고 빨간색이 함수의 결과 좌표입니다.

DataTexture를 이용한 Raw 데이터로 텍스쳐 생성

    const width = 256;
    const height = 256;
    const size = width * height;
    const data = new Uint8Array(4 * size); // RGBA 데이터

    for (let i = 0; i < size; i++) {
      const stride = i * 4;

      data[stride] = Math.floor(Math.random() * 256);     // R
      data[stride + 1] = Math.floor(Math.random() * 256); // G
      data[stride + 2] = Math.floor(Math.random() * 256); // B
      data[stride + 3] = 255;                             // A
    }

    const texture = new THREE.DataTexture(data, width, height);
    texture.needsUpdate = true;

    const material = new THREE.MeshBasicMaterial({ map: texture });
    const mesh = new THREE.Mesh(new THREE.PlaneGeometry(4, 4), material);
    this._scene.add(mesh);

여러 개의 지오메트리들을 한 개의 지오메트리로 병합하기

const mat3 = new THREE.MeshBasicMaterial();
const pumpkin = await this.loadGLTF('./resources/models/pumpkin.glb');
const geos = [];

// 아래의 2줄에 대한 변환 코드는 updateMatrixWorld 매서드가 실행되면 변환 값이 matrixWorld에 반영됨
pumpkin.children[0].position.set(0, 0, 0);
pumpkin.children[0].scale.setScalar(0.01);

pumpkin.children[0].traverse(child => {
  child.updateMatrixWorld(); 
  if(child.isMesh) {
    if(child.geometry) {
      const attribute = child.geometry.getAttribute('position').clone();
      const geometry = new THREE.BufferGeometry();
      geometry.setIndex(child.geometry.index);
      geometry.setAttribute('position', attribute);
      geometry.applyMatrix4(child.matrixWorld);
      geos.push(geometry);
    }
  }
});
const combinedGeometry = BufferGeometryUtils.mergeGeometries(geos);
const combinedMesh = new THREE.Mesh(combinedGeometry, mat3);

다중 파일의 드랍에 대한 안전한 처리

웹 API가 계속 발전하고 그에 맞춰 Javascript도 개선되는 과정에서 괴리가 발생하는데요. 그중 한가지 사례로 다중 파일에 대한 드랍처리입니다.

아래의 드랍 처리는 2개 이상의 파일을 드랍했을때 처리되어야할 파일이 누락됩니다.

dropArea.addEventListener('drop', async (e) => {
  e.preventDefault();
  dropArea.classList.remove('highlight');
  fileListElement.innerHTML = '';

  const items = e.dataTransfer.items;
  if (items) {
    for (let i = 0; i < items.length; i++) {
      try {
        const handle = await items[i].getAsFileSystemHandle();

        if (handle.kind === 'file') {
          const file = await handle.getFile();
          displayFile(file);
        } else if (handle.kind === 'directory') {
          await traverseFileSystemHandle(handle);
        }
      } catch (error) {
        console.error(error);
      }
    }
  }
});

위 코드의 문제점에 대한 원인은 비동기 처리를 위해 js에서는 for await .. of 문이 도입되었으나 아직 drop 이벤트 객체의 dataTransfer.items 컬렉션은 이를 지원하지 않기 때문입니다. 차선책으로 이를 개선한 코드가 다음과 같습니다.

dropArea.addEventListener('drop', async (e) => {
  e.preventDefault();
  dropArea.classList.remove('highlight');

  fileListElement.innerHTML = '';

  const items = e.dataTransfer.items;
  if (items) {
    const processingPromises = [];

    for (let i = 0; i < items.length; i++) {
      processingPromises.push((async () => {
        if (items[i].getAsFileSystemHandle) {
          try {
            const handle = await items[i].getAsFileSystemHandle();
            if (handle.kind === 'file') {
              const file = await handle.getFile();
              displayFile(file);
            } else if (handle.kind === 'directory') {
              await traverseFileSystemHandle(handle);
            }
          } catch (error) {
            console.warn(error);
          }
        }
      })()); // 즉시 실행
    }
    await Promise.allSettled(processingPromises);
  }
});