@liquidcars/atlas 0.1.39 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liquidcars/atlas",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "Reusable 3D conceptual atlas Web Component for LiquidCars-style models",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -76,6 +76,48 @@ function normalizeGeometryResource(resource, index, sourceIds) {
76
76
  };
77
77
  }
78
78
 
79
+ function normalizeGeometryShowcase(pack, namespace) {
80
+ if (!pack.showcase || typeof pack.showcase !== "object") return null;
81
+ const value = cloneGeometryMetadata(pack.showcase);
82
+ const groups = Array.isArray(value.groups) ? value.groups : [];
83
+ const groupIds = new Set();
84
+ const itemIds = new Set();
85
+ const normalizedGroups = groups.map((source, groupIndex) => {
86
+ const group = source && typeof source === "object" ? source : {};
87
+ const id = String(group.id || `group-${groupIndex + 1}`).trim();
88
+ if (!id || groupIds.has(id)) throw new TypeError(`Geometry pack '${namespace}' showcase contains a duplicate or empty group id.`);
89
+ groupIds.add(id);
90
+ const items = Array.isArray(group.items) ? group.items : Array.isArray(group.children) ? group.children : [];
91
+ return {
92
+ ...group,
93
+ id,
94
+ name: String(group.name || group.title || id),
95
+ items: items.map((sourceItem, itemIndex) => {
96
+ const item = sourceItem && typeof sourceItem === "object" ? sourceItem : {};
97
+ const geometry = String(item.geometry || item.kind || "").trim();
98
+ if (!geometry) throw new TypeError(`Geometry pack '${namespace}' showcase item ${itemIndex + 1} in '${id}' requires a geometry.`);
99
+ const itemId = String(item.id || `${id}-${itemIndex + 1}`).trim();
100
+ if (!itemId || itemIds.has(itemId)) throw new TypeError(`Geometry pack '${namespace}' showcase contains a duplicate or empty item id.`);
101
+ itemIds.add(itemId);
102
+ return {
103
+ ...item,
104
+ id: itemId,
105
+ geometry: geometry.includes(".") ? geometry : `${namespace}.${geometry}`,
106
+ geometryOptions: cloneGeometryMetadata(item.geometryOptions || item.options || item.parameters || {}),
107
+ geometryPalette: cloneGeometryMetadata(item.geometryPalette || item.palette || {})
108
+ };
109
+ })
110
+ };
111
+ });
112
+ return {
113
+ ...value,
114
+ title: String(value.title || pack.name || namespace),
115
+ description: String(value.description || ""),
116
+ groups: normalizedGroups,
117
+ relations: cloneGeometryMetadata(Array.isArray(value.relations) ? value.relations : [])
118
+ };
119
+ }
120
+
79
121
  function normalizeGeometryPackMetadata(pack, namespace) {
80
122
  const sources = (Array.isArray(pack.sources) ? pack.sources : []).map(normalizeGeometrySource);
81
123
  const sourceIds = new Set(sources.map(source => source.id));
@@ -96,6 +138,7 @@ function normalizeGeometryPackMetadata(pack, namespace) {
96
138
  license: normalizeGeometryLicense(pack.license),
97
139
  attribution: String(pack.attribution || ""),
98
140
  collections: cloneGeometryMetadata(Array.isArray(pack.collections) ? pack.collections : []),
141
+ showcase: normalizeGeometryShowcase(pack, namespace),
99
142
  sources,
100
143
  resources,
101
144
  geometries: []
@@ -491,8 +534,227 @@ function iconTexture(value, foreground = "#ffffff", background = "transparent")
491
534
  return texture;
492
535
  }
493
536
 
537
+ const SVG_PATH_COMMAND = /^[AaCcHhLlMmQqSsTtVvZz]$/;
538
+ const SVG_PATH_TOKEN = /[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?|[AaCcHhLlMmQqSsTtVvZz]/g;
539
+
540
+ function svgArcPoints(x1, y1, rxValue, ryValue, rotation, largeArc, sweep, x2, y2) {
541
+ let rx = Math.abs(Number(rxValue));
542
+ let ry = Math.abs(Number(ryValue));
543
+ if (!rx || !ry || (x1 === x2 && y1 === y2)) return [[x2, y2]];
544
+ const phi = Number(rotation || 0) * Math.PI / 180;
545
+ const cosPhi = Math.cos(phi);
546
+ const sinPhi = Math.sin(phi);
547
+ const dx = (x1 - x2) / 2;
548
+ const dy = (y1 - y2) / 2;
549
+ const xPrime = cosPhi * dx + sinPhi * dy;
550
+ const yPrime = -sinPhi * dx + cosPhi * dy;
551
+ const scale = Math.sqrt(Math.max(1, xPrime * xPrime / (rx * rx) + yPrime * yPrime / (ry * ry)));
552
+ rx *= scale;
553
+ ry *= scale;
554
+ const numerator = Math.max(0, rx * rx * ry * ry - rx * rx * yPrime * yPrime - ry * ry * xPrime * xPrime);
555
+ const denominator = Math.max(Number.EPSILON, rx * rx * yPrime * yPrime + ry * ry * xPrime * xPrime);
556
+ const sign = Boolean(largeArc) === Boolean(sweep) ? -1 : 1;
557
+ const coefficient = sign * Math.sqrt(numerator / denominator);
558
+ const centerPrimeX = coefficient * rx * yPrime / ry;
559
+ const centerPrimeY = coefficient * -ry * xPrime / rx;
560
+ const centerX = cosPhi * centerPrimeX - sinPhi * centerPrimeY + (x1 + x2) / 2;
561
+ const centerY = sinPhi * centerPrimeX + cosPhi * centerPrimeY + (y1 + y2) / 2;
562
+ const angleBetween = (ux, uy, vx, vy) => {
563
+ const divisor = Math.max(Number.EPSILON, Math.hypot(ux, uy) * Math.hypot(vx, vy));
564
+ const angle = Math.acos(Math.max(-1, Math.min(1, (ux * vx + uy * vy) / divisor)));
565
+ return ux * vy - uy * vx < 0 ? -angle : angle;
566
+ };
567
+ const startX = (xPrime - centerPrimeX) / rx;
568
+ const startY = (yPrime - centerPrimeY) / ry;
569
+ const endX = (-xPrime - centerPrimeX) / rx;
570
+ const endY = (-yPrime - centerPrimeY) / ry;
571
+ const startAngle = angleBetween(1, 0, startX, startY);
572
+ let deltaAngle = angleBetween(startX, startY, endX, endY);
573
+ if (!sweep && deltaAngle > 0) deltaAngle -= Math.PI * 2;
574
+ if (sweep && deltaAngle < 0) deltaAngle += Math.PI * 2;
575
+ const steps = Math.max(6, Math.ceil(Math.abs(deltaAngle) / (Math.PI / 12)));
576
+ return Array.from({ length: steps }, (_, index) => {
577
+ const angle = startAngle + deltaAngle * (index + 1) / steps;
578
+ const cosAngle = Math.cos(angle);
579
+ const sinAngle = Math.sin(angle);
580
+ return [
581
+ centerX + cosPhi * rx * cosAngle - sinPhi * ry * sinAngle,
582
+ centerY + sinPhi * rx * cosAngle + cosPhi * ry * sinAngle
583
+ ];
584
+ });
585
+ }
586
+
587
+ function svgShapePath(pathData) {
588
+ const tokens = String(pathData || "").match(SVG_PATH_TOKEN) || [];
589
+ const shapePath = new THREE.ShapePath();
590
+ let index = 0;
591
+ let command = "";
592
+ let previousCommand = "";
593
+ let x = 0;
594
+ let y = 0;
595
+ let startX = 0;
596
+ let startY = 0;
597
+ let cubicControlX = 0;
598
+ let cubicControlY = 0;
599
+ let quadraticControlX = 0;
600
+ let quadraticControlY = 0;
601
+ const hasNumber = () => index < tokens.length && !SVG_PATH_COMMAND.test(tokens[index]);
602
+ const number = () => Number(tokens[index++]);
603
+ const flag = () => {
604
+ const token = tokens[index];
605
+ // SVG permits arc flags to touch one another and the following coordinate
606
+ // (for example `0 003.839 0` means flags 0/0 and x=3.839).
607
+ if (/^[01].+/.test(token)) {
608
+ tokens[index] = token.slice(1);
609
+ return token[0] === "1";
610
+ }
611
+ return number() !== 0;
612
+ };
613
+ const point = (nextX, nextY, relative) => [relative ? x + nextX : nextX, relative ? y + nextY : nextY];
614
+
615
+ while (index < tokens.length) {
616
+ if (SVG_PATH_COMMAND.test(tokens[index])) command = tokens[index++];
617
+ if (!command) break;
618
+ const lower = command.toLowerCase();
619
+ const relative = command === lower;
620
+
621
+ if (lower === "z") {
622
+ shapePath.currentPath?.closePath();
623
+ x = startX;
624
+ y = startY;
625
+ previousCommand = command;
626
+ command = "";
627
+ continue;
628
+ }
629
+
630
+ if (!hasNumber()) break;
631
+ if (lower === "m") {
632
+ let first = true;
633
+ while (hasNumber()) {
634
+ const [nextX, nextY] = point(number(), number(), relative);
635
+ x = nextX;
636
+ y = nextY;
637
+ if (first) {
638
+ shapePath.moveTo(x, -y);
639
+ startX = x;
640
+ startY = y;
641
+ first = false;
642
+ } else shapePath.lineTo(x, -y);
643
+ }
644
+ } else if (lower === "l") {
645
+ while (hasNumber()) {
646
+ [x, y] = point(number(), number(), relative);
647
+ shapePath.lineTo(x, -y);
648
+ }
649
+ } else if (lower === "h") {
650
+ while (hasNumber()) {
651
+ const value = number();
652
+ x = relative ? x + value : value;
653
+ shapePath.lineTo(x, -y);
654
+ }
655
+ } else if (lower === "v") {
656
+ while (hasNumber()) {
657
+ const value = number();
658
+ y = relative ? y + value : value;
659
+ shapePath.lineTo(x, -y);
660
+ }
661
+ } else if (lower === "c") {
662
+ while (hasNumber()) {
663
+ const [control1X, control1Y] = point(number(), number(), relative);
664
+ const [control2X, control2Y] = point(number(), number(), relative);
665
+ const [nextX, nextY] = point(number(), number(), relative);
666
+ shapePath.currentPath?.bezierCurveTo(control1X, -control1Y, control2X, -control2Y, nextX, -nextY);
667
+ x = nextX;
668
+ y = nextY;
669
+ cubicControlX = control2X;
670
+ cubicControlY = control2Y;
671
+ previousCommand = command;
672
+ }
673
+ } else if (lower === "s") {
674
+ while (hasNumber()) {
675
+ const reflectedX = ["c", "s"].includes(previousCommand.toLowerCase()) ? x * 2 - cubicControlX : x;
676
+ const reflectedY = ["c", "s"].includes(previousCommand.toLowerCase()) ? y * 2 - cubicControlY : y;
677
+ const [control2X, control2Y] = point(number(), number(), relative);
678
+ const [nextX, nextY] = point(number(), number(), relative);
679
+ shapePath.currentPath?.bezierCurveTo(reflectedX, -reflectedY, control2X, -control2Y, nextX, -nextY);
680
+ x = nextX;
681
+ y = nextY;
682
+ cubicControlX = control2X;
683
+ cubicControlY = control2Y;
684
+ previousCommand = command;
685
+ }
686
+ } else if (lower === "q") {
687
+ while (hasNumber()) {
688
+ const [controlX, controlY] = point(number(), number(), relative);
689
+ const [nextX, nextY] = point(number(), number(), relative);
690
+ shapePath.currentPath?.quadraticCurveTo(controlX, -controlY, nextX, -nextY);
691
+ x = nextX;
692
+ y = nextY;
693
+ quadraticControlX = controlX;
694
+ quadraticControlY = controlY;
695
+ previousCommand = command;
696
+ }
697
+ } else if (lower === "t") {
698
+ while (hasNumber()) {
699
+ const controlX = ["q", "t"].includes(previousCommand.toLowerCase()) ? x * 2 - quadraticControlX : x;
700
+ const controlY = ["q", "t"].includes(previousCommand.toLowerCase()) ? y * 2 - quadraticControlY : y;
701
+ const [nextX, nextY] = point(number(), number(), relative);
702
+ shapePath.currentPath?.quadraticCurveTo(controlX, -controlY, nextX, -nextY);
703
+ x = nextX;
704
+ y = nextY;
705
+ quadraticControlX = controlX;
706
+ quadraticControlY = controlY;
707
+ previousCommand = command;
708
+ }
709
+ } else if (lower === "a") {
710
+ while (hasNumber()) {
711
+ const rx = number();
712
+ const ry = number();
713
+ const rotation = number();
714
+ const largeArc = flag();
715
+ const sweep = flag();
716
+ const [nextX, nextY] = point(number(), number(), relative);
717
+ for (const [arcX, arcY] of svgArcPoints(x, y, rx, ry, rotation, largeArc, sweep, nextX, nextY)) {
718
+ shapePath.lineTo(arcX, -arcY);
719
+ }
720
+ x = nextX;
721
+ y = nextY;
722
+ previousCommand = command;
723
+ }
724
+ } else break;
725
+
726
+ previousCommand = command;
727
+ }
728
+ return shapePath;
729
+ }
730
+
731
+ export function createSvgExtrudeGeometry(source, options = {}) {
732
+ const pathTags = String(source || "").match(/<path\b[^>]*>/gi) || [];
733
+ const shapes = [];
734
+ for (const tag of pathTags) {
735
+ const match = tag.match(/\bd\s*=\s*(["'])([\s\S]*?)\1/i);
736
+ if (!match) continue;
737
+ const shapePath = svgShapePath(match[2]);
738
+ shapes.push(...shapePath.toShapes(true, false));
739
+ }
740
+ if (!shapes.length) return new THREE.BoxGeometry(1, 1, .12);
741
+ const bevel = Math.max(0, Math.min(.2, Number(options.bevel) || .055));
742
+ const geometry = new THREE.ExtrudeGeometry(shapes, {
743
+ depth: 1,
744
+ steps: 1,
745
+ curveSegments: Math.max(2, Math.round(Number(options.curveSegments) || 8)),
746
+ bevelEnabled: bevel > 0,
747
+ bevelSegments: Math.max(1, Math.round(Number(options.bevelSegments) || 2)),
748
+ bevelSize: bevel,
749
+ bevelThickness: bevel
750
+ });
751
+ geometry.center();
752
+ return geometry;
753
+ }
754
+
494
755
  function partGeometry(part) {
495
756
  if (part.shape === "plane") return new THREE.PlaneGeometry(1, 1);
757
+ if (part.shape === "svg-extrude") return createSvgExtrudeGeometry(part.svg, part);
496
758
  return makeGeometry(part.shape || part.geometry || "box", part.radius, part.segments, part.size);
497
759
  }
498
760
 
@@ -518,8 +780,14 @@ function createGeometryContent(definition, entity, modelPalette, invalidate, rep
518
780
  normalizeGeometry(geometry);
519
781
  const colorValue = part.color || "component";
520
782
  const color = resolveGeometryColor(colorValue, localPalette, modelPalette.component || DEFAULT_PALETTE.component);
783
+ const emissiveValue = part.emissive || "#000000";
784
+ const emissive = resolveGeometryColor(emissiveValue, localPalette, "#000000");
521
785
  const material = new THREE.MeshStandardMaterial({
522
786
  color,
787
+ emissive,
788
+ emissiveIntensity: Number.isFinite(Number(part.emissiveIntensity))
789
+ ? Math.max(0, Number(part.emissiveIntensity))
790
+ : 0,
523
791
  roughness: Number.isFinite(Number(part.roughness)) ? Number(part.roughness) : .72,
524
792
  metalness: Number.isFinite(Number(part.metalness)) ? Number(part.metalness) : .06,
525
793
  transparent: part.transparent === true || Number(part.opacity) < 1,
@@ -646,7 +914,7 @@ function createGeometryContent(definition, entity, modelPalette, invalidate, rep
646
914
  if (Array.isArray(part.rotation)) mesh.rotation.set(...part.rotation.map(Number));
647
915
  root.add(mesh);
648
916
  meshes.push(mesh);
649
- colorBindings.push({ material, value: colorValue, palette: localPalette });
917
+ colorBindings.push({ material, value: colorValue, emissiveValue, palette: localPalette });
650
918
  if (part.edges !== false && part.shape !== "plane") {
651
919
  const edgeMaterial = new THREE.LineBasicMaterial({ color, transparent: true, opacity: Number.isFinite(Number(part.edgeOpacity)) ? Number(part.edgeOpacity) : .55 });
652
920
  const edge = new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 22), edgeMaterial);
@@ -666,6 +934,27 @@ function createGeometryContent(definition, entity, modelPalette, invalidate, rep
666
934
  return { root, meshes, colorBindings, parameters, descriptor };
667
935
  }
668
936
 
937
+ const LABEL_ANCHORS = Object.freeze(["auto", "top", "bottom", "left", "right"]);
938
+
939
+ export function normalizeLabelAnchor(value) {
940
+ const anchor = String(value || "auto").trim().toLowerCase();
941
+ return LABEL_ANCHORS.includes(anchor) ? anchor : "auto";
942
+ }
943
+
944
+ export function labelPlacementForAnchor(value, dimensions, labelWidth, labelHeight, gap = .025) {
945
+ const anchor = normalizeLabelAnchor(value);
946
+ if (anchor === "auto") return null;
947
+ const width = Math.max(0, Number(dimensions?.x) || 0);
948
+ const height = Math.max(0, Number(dimensions?.y) || 0);
949
+ const textWidth = Math.max(0, Number(labelWidth) || 0);
950
+ const textHeight = Math.max(0, Number(labelHeight) || 0);
951
+ const spacing = Math.max(0, Number(gap) || 0);
952
+ if (anchor === "top") return { x: 0, y: height / 2 + spacing + textHeight / 2, frontRotation: 0, backRotation: 0 };
953
+ if (anchor === "bottom") return { x: 0, y: -height / 2 - spacing - textHeight / 2, frontRotation: 0, backRotation: 0 };
954
+ if (anchor === "left") return { x: -width / 2 - spacing - textHeight / 2, y: 0, frontRotation: Math.PI / 2, backRotation: -Math.PI / 2 };
955
+ return { x: width / 2 + spacing + textHeight / 2, y: 0, frontRotation: -Math.PI / 2, backRotation: Math.PI / 2 };
956
+ }
957
+
669
958
  function makeLabel(text, color, dimensions, type = "", outlined = true) {
670
959
  const bitmap = document.createElement("canvas");
671
960
  const context = bitmap.getContext("2d");
@@ -1880,6 +2169,9 @@ class LiquidCarsAtlas extends HTMLElement {
1880
2169
  for (const binding of node.colorBindings || []) {
1881
2170
  const palette = { ...this.modelData.palette, ...(node.geometryPalette || {}), ...(node.geometryPaletteOverrides || {}) };
1882
2171
  binding.material.color.set(resolveGeometryColor(binding.value, palette, color));
2172
+ if (binding.material.emissive && binding.emissiveValue) {
2173
+ binding.material.emissive.set(resolveGeometryColor(binding.emissiveValue, palette, "#000000"));
2174
+ }
1883
2175
  }
1884
2176
  }
1885
2177
  }
@@ -1969,6 +2261,7 @@ class LiquidCarsAtlas extends HTMLElement {
1969
2261
  node.group.add(node.edge);
1970
2262
  }
1971
2263
  node.label = makeLabel(entity.name, color, node.labelSize, entity.type, this.showShellLabels);
2264
+ node.labelAnchor = normalizeLabelAnchor(entity.style?.label?.anchor ?? entity.labelAnchor ?? entity.label_anchor);
1972
2265
  node.label.userData.node = node;
1973
2266
  node.label.traverse(labelFace => { labelFace.userData.node = node; });
1974
2267
  node.group.add(node.label);
@@ -2299,6 +2592,7 @@ class LiquidCarsAtlas extends HTMLElement {
2299
2592
  const textOffsetX = node.label.userData.textOffsetX || 0;
2300
2593
  const textOffsetY = node.label.userData.textOffsetY || 0;
2301
2594
  const labelSize = node.labelSize || new THREE.Vector3().fromArray(node.s);
2595
+ const floatingLabel = labelPlacementForAnchor(node.labelAnchor, labelSize, labelWidth, labelHeight);
2302
2596
  node.label.visible = visible;
2303
2597
  // Only paint the face that is oriented towards the camera. Both faces
2304
2598
  // used to be depth-test disabled, so the back label could be drawn over
@@ -2308,7 +2602,23 @@ class LiquidCarsAtlas extends HTMLElement {
2308
2602
  ? true
2309
2603
  : this.camera.position.z >= center.z;
2310
2604
  const cameraAbove = this.camera.position.y > center.y + .25;
2311
- if (isContainerType(node.type)) {
2605
+ if (floatingLabel) {
2606
+ front.visible = cameraOnFront;
2607
+ back.visible = !cameraOnFront;
2608
+ top.visible = false;
2609
+ front.scale.setScalar(1);
2610
+ back.scale.setScalar(1);
2611
+ front.position.set(floatingLabel.x, floatingLabel.y, labelSize.z / 2 + .025);
2612
+ back.position.set(floatingLabel.x, floatingLabel.y, -labelSize.z / 2 - .025);
2613
+ front.rotation.z = floatingLabel.frontRotation;
2614
+ back.rotation.z = floatingLabel.backRotation;
2615
+ front.material.color.set(0xf2fafc);
2616
+ back.material.color.set(0xf2fafc);
2617
+ front.material.opacity = focusAlpha;
2618
+ back.material.opacity = focusAlpha;
2619
+ } else if (isContainerType(node.type)) {
2620
+ front.rotation.z = 0;
2621
+ back.rotation.z = 0;
2312
2622
  front.visible = cameraOnFront && (!transparentLabel || displayTransparentLabel);
2313
2623
  back.visible = !cameraOnFront && (!transparentLabel || displayTransparentLabel);
2314
2624
  // The upper label is useful only when the camera actually sees the
@@ -2342,12 +2652,18 @@ class LiquidCarsAtlas extends HTMLElement {
2342
2652
  top.material.color.set(displayTransparentLabel ? 0xa4b0bb : 0xf2fafc);
2343
2653
  top.material.opacity = (displayTransparentLabel ? .82 : 1) * focusAlpha;
2344
2654
  } else {
2655
+ front.rotation.z = 0;
2656
+ back.rotation.z = 0;
2345
2657
  front.visible = cameraOnFront && !opened;
2346
2658
  back.visible = !cameraOnFront && !opened;
2347
2659
  top.visible = false;
2348
2660
  front.scale.setScalar(1);
2349
2661
  front.position.x = 0;
2350
2662
  front.position.y = labelSize.y / 2 - .09 - labelHeight / 2;
2663
+ front.position.z = labelSize.z / 2 + .012;
2664
+ back.position.x = 0;
2665
+ back.position.y = labelSize.y / 2 - .09 - labelHeight / 2;
2666
+ back.position.z = -labelSize.z / 2 - .012;
2351
2667
  front.material.color.set(0xf2fafc);
2352
2668
  front.material.opacity = focusAlpha;
2353
2669
  back.material.opacity = focusAlpha;