@jfk-solutions/demo3d-file-format 1.4.0 → 1.6.0

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.
@@ -1,5 +1,6 @@
1
1
  import { Demo3DConveyorSideProperties, Demo3DDimensionAspect, Demo3DDimensionPoint, Demo3DExtrusionPolygon, Demo3DExtrusionProfile, Demo3DMaterial, Demo3DMesh, Demo3DPackage, Demo3DProject, Demo3DPhotoEye, Demo3DResource, Demo3DSupportStand, Demo3DVisual } from "../model.js";
2
2
  import { Demo3DBinaryBlock, Demo3DXmlElement } from "../xml.js";
3
+ export { createRaw3DThreeGroup, createRaw3DThreeScene, decodeRaw3DThreeGeometry } from "./raw3d.js";
3
4
  export { createDemo3DThreeRenderer } from "./renderer.js";
4
5
  export async function createDemo3DThreeScene(parsed, options = {}) {
5
6
  const three = await resolveThree(options);
@@ -88,6 +89,7 @@ export function decodeDemo3DThreeGeometry(mesh, three) {
88
89
  if (mesh.indices) {
89
90
  geometry.setIndex(new three.BufferAttribute(readIndexBuffer(mesh.indices, mesh.indexFormat), 1));
90
91
  }
92
+ addMeshSubsetGroups(geometry, mesh);
91
93
  convertTriangleGeometryToThreeCoordinates(geometry);
92
94
  if (!normals) {
93
95
  geometry.computeVertexNormals();
@@ -96,6 +98,25 @@ export function decodeDemo3DThreeGeometry(mesh, three) {
96
98
  geometry.computeBoundingSphere();
97
99
  return geometry;
98
100
  }
101
+ function addMeshSubsetGroups(geometry, mesh) {
102
+ const subsets = mesh.auxiliary?.toUint8Array();
103
+ const drawCount = geometry.getIndex()?.count ?? geometry.getAttribute("position").count;
104
+ const faceCount = Math.floor(drawCount / 3);
105
+ if (!subsets || subsets.length !== faceCount || faceCount === 0) {
106
+ return;
107
+ }
108
+ let runStart = 0;
109
+ let materialIndex = subsets[0] ?? 0;
110
+ for (let face = 1; face <= faceCount; face += 1) {
111
+ const nextMaterialIndex = face < faceCount ? subsets[face] : undefined;
112
+ if (face < faceCount && nextMaterialIndex === materialIndex) {
113
+ continue;
114
+ }
115
+ geometry.addGroup(runStart * 3, (face - runStart) * 3, materialIndex);
116
+ runStart = face;
117
+ materialIndex = nextMaterialIndex ?? 0;
118
+ }
119
+ }
99
120
  export function createDemo3DThreeMaterial(material, three) {
100
121
  const diffuse = material?.diffuse;
101
122
  const color = diffuse === undefined ? 0x9aa0a6 : demo3dColorToHex(diffuse);
@@ -151,6 +172,7 @@ function createState(three, project, options, buffers) {
151
172
  lines: 0,
152
173
  directVisuals: 0,
153
174
  proceduralBelts: 0,
175
+ proceduralRacks: 0,
154
176
  proceduralSupportStands: 0,
155
177
  proceduralConveyorSides: 0,
156
178
  proceduralPhotoEyes: 0,
@@ -191,7 +213,7 @@ function createVisualObject(visual, state, inheritedLayer, parentVisual) {
191
213
  meshes.push(directVisualObject);
192
214
  }
193
215
  for (const ref of refs) {
194
- const mesh = createMeshObject(ref, visual, visual.materials[0], state);
216
+ const mesh = createMeshObject(ref, visual, visual.materials, state);
195
217
  if (mesh) {
196
218
  if (mesh.userData.demo3d?.kind === "missing-geometry-placeholder") {
197
219
  placeholderObjects.push(mesh);
@@ -280,11 +302,13 @@ function createAspectRenderableObjects(visual, state) {
280
302
  for (const renderable of renderables.children) {
281
303
  const meshIds = findMeshReferenceIds(renderable);
282
304
  for (const meshId of meshIds) {
283
- const material = firstMaterial(renderable) ?? visual.materials[0];
305
+ const serializedMaterials = renderableMaterials(renderable);
306
+ const materials = serializedMaterials.length > 0 ? serializedMaterials : visual.materials;
307
+ const primaryMaterial = materials[0];
284
308
  const mesh = state.meshById.has(meshId)
285
- ? createMeshObject(meshId, renderable, material, state)
286
- : createPrimitiveRenderableObject(aspect, renderable, meshId, material, state) ??
287
- createMeshObject(meshId, renderable, material, state);
309
+ ? createMeshObject(meshId, renderable, materials, state)
310
+ : createPrimitiveRenderableObject(aspect, renderable, meshId, primaryMaterial, state) ??
311
+ createMeshObject(meshId, renderable, materials, state);
288
312
  if (!mesh) {
289
313
  continue;
290
314
  }
@@ -380,9 +404,16 @@ function createDirectVisualObject(visual, state, parentVisual) {
380
404
  if (visual.typeName === "e3d:ImportedImageVisual") {
381
405
  return createImportedImageObject(visual, state);
382
406
  }
383
- if (visual.typeName === "e3d:StraightBeltConveyor") {
407
+ if (visual.typeName === "e3d:StraightBeltConveyor" ||
408
+ visual.typeName === "e3d:CurveBeltConveyor" ||
409
+ visual.typeName === "e3d:InjectorBeltConveyor") {
384
410
  return state.options.renderProceduralBelts === true
385
- ? createStraightBeltConveyorObject(visual, state)
411
+ ? createBeltConveyorObject(visual, state)
412
+ : undefined;
413
+ }
414
+ if (visual.typeName === "e3d:RackVisual") {
415
+ return state.options.renderProceduralRacks === true
416
+ ? createRackObject(visual, state)
386
417
  : undefined;
387
418
  }
388
419
  if (visual.typeName === "e3d:SupportStand") {
@@ -415,7 +446,16 @@ function createDirectVisualObject(visual, state, parentVisual) {
415
446
  state.stats.directVisuals += 1;
416
447
  return object;
417
448
  }
418
- function createStraightBeltConveyorObject(visual, state) {
449
+ function createBeltConveyorObject(visual, state) {
450
+ if (visual.typeName === "e3d:CurveBeltConveyor") {
451
+ return createCurveBeltConveyorObject(visual, state);
452
+ }
453
+ if (visual.typeName === "e3d:InjectorBeltConveyor") {
454
+ const injector = createInjectorBeltConveyorObject(visual, state);
455
+ if (injector) {
456
+ return injector;
457
+ }
458
+ }
419
459
  const belt = readProceduralBelt(visual, state);
420
460
  if (!belt) {
421
461
  return undefined;
@@ -444,6 +484,146 @@ function createStraightBeltConveyorObject(visual, state) {
444
484
  state.stats.proceduralBelts += 1;
445
485
  return object;
446
486
  }
487
+ const DEFAULT_BELT_WIDTH = 0.5;
488
+ const DEFAULT_BELT_DIAMETER = 0.06;
489
+ function appendOrientedTriangle(target, a, b, c, normal) {
490
+ const abx = b[0] - a[0];
491
+ const aby = b[1] - a[1];
492
+ const abz = b[2] - a[2];
493
+ const acx = c[0] - a[0];
494
+ const acy = c[1] - a[1];
495
+ const acz = c[2] - a[2];
496
+ const crossX = aby * acz - abz * acy;
497
+ const crossY = abz * acx - abx * acz;
498
+ const crossZ = abx * acy - aby * acx;
499
+ const forward = crossX * normal[0] + crossY * normal[1] + crossZ * normal[2] >= 0;
500
+ target.push(...a, ...(forward ? b : c), ...(forward ? c : b));
501
+ }
502
+ function appendOrientedQuad(target, a, b, c, d, normal) {
503
+ appendOrientedTriangle(target, a, b, c, normal);
504
+ appendOrientedTriangle(target, a, c, d, normal);
505
+ }
506
+ function createInjectorBeltConveyorObject(visual, state) {
507
+ const belt = readProceduralInjectorBelt(visual);
508
+ if (!belt) {
509
+ return undefined;
510
+ }
511
+ const geometry = getProceduralInjectorBeltGeometry(belt, state);
512
+ const object = new state.three.Mesh(geometry, [
513
+ getMaterial(belt.surfaceMaterial, state),
514
+ getMaterial(belt.sideMaterial, state)
515
+ ]);
516
+ object.name = visual.displayName ?? visual.id ?? visual.typeName;
517
+ object.userData.demo3d = {
518
+ kind: "procedural-belt",
519
+ id: visual.id,
520
+ name: visual.displayName,
521
+ typeName: visual.typeName,
522
+ xmlPath: visual.xml.path,
523
+ loopHeight: belt.thickness,
524
+ startEdge: belt.startEdge,
525
+ endEdge: belt.endEdge,
526
+ connectorShaped: true
527
+ };
528
+ state.stats.meshes += 1;
529
+ state.stats.directVisuals += 1;
530
+ state.stats.proceduralBelts += 1;
531
+ return object;
532
+ }
533
+ function readProceduralInjectorBelt(visual) {
534
+ const properties = visual.properties;
535
+ if (!properties || isFalse(properties.textOf("CenterVisible"))) {
536
+ return undefined;
537
+ }
538
+ const startEdge = conveyorConnectorEdge(visual, "Start");
539
+ const serializedEndEdge = conveyorConnectorEdge(visual, "End");
540
+ if (!startEdge || !serializedEndEdge) {
541
+ return undefined;
542
+ }
543
+ const sameDirection = pointDistanceSquared(startEdge[0], serializedEndEdge[0]) +
544
+ pointDistanceSquared(startEdge[1], serializedEndEdge[1]);
545
+ const reversedDirection = pointDistanceSquared(startEdge[0], serializedEndEdge[1]) +
546
+ pointDistanceSquared(startEdge[1], serializedEndEdge[0]);
547
+ const endEdge = sameDirection <= reversedDirection
548
+ ? serializedEndEdge
549
+ : [serializedEndEdge[1], serializedEndEdge[0]];
550
+ const beltDiameter = numberChildOptional(properties, "CenterDiameter") ??
551
+ numberChildOptional(properties, "BeltDiameter") ??
552
+ DEFAULT_BELT_DIAMETER;
553
+ const thickness = isTrue(properties.textOf("UseBeltCenterHeight"))
554
+ ? numberChild(properties, "BeltCenterHeight", beltDiameter)
555
+ : beltDiameter;
556
+ if (thickness <= 0) {
557
+ return undefined;
558
+ }
559
+ const surfaceMaterial = firstMaterial(properties.child("SurfaceMaterial") ?? properties);
560
+ const sideMaterial = firstMaterial(properties.child("SurfaceSideMaterial") ?? properties) ?? surfaceMaterial;
561
+ return { startEdge, endEdge, thickness, surfaceMaterial, sideMaterial };
562
+ }
563
+ function conveyorConnectorEdge(visual, name) {
564
+ const connector = visual.xml.child("CN")?.children.find((candidate) => candidate.xsiType === "e3d:ConveyorConnector" && candidate.textOf("Name") === name);
565
+ if (!connector) {
566
+ return undefined;
567
+ }
568
+ const start = pipePoint(connector.textOf("Start"));
569
+ const end = pipePoint(connector.textOf("End"));
570
+ return start && end ? [start, end] : undefined;
571
+ }
572
+ function pipePoint(value) {
573
+ const values = parsePipeNumbers(value);
574
+ if (values.length === 0) {
575
+ return undefined;
576
+ }
577
+ return [values[0] ?? 0, values[1] ?? 0, -(values[2] ?? 0)];
578
+ }
579
+ function pointDistanceSquared(left, right) {
580
+ return ((left[0] - right[0]) ** 2 +
581
+ (left[1] - right[1]) ** 2 +
582
+ (left[2] - right[2]) ** 2);
583
+ }
584
+ function getProceduralInjectorBeltGeometry(belt, state) {
585
+ const key = `injector-belt:${[...belt.startEdge, ...belt.endEdge].map((point) => point.join(",")).join(";")}:${belt.thickness}`;
586
+ const cached = state.primitiveGeometryCache.get(key);
587
+ if (cached) {
588
+ return cached;
589
+ }
590
+ const surfacePositions = [];
591
+ const sidePositions = [];
592
+ const top = [belt.startEdge[0], belt.startEdge[1], belt.endEdge[1], belt.endEdge[0]];
593
+ const bottom = top.map((point) => [point[0], point[1] - belt.thickness, point[2]]);
594
+ const center = [
595
+ top.reduce((sum, point) => sum + point[0], 0) / top.length,
596
+ top.reduce((sum, point) => sum + point[1], 0) / top.length,
597
+ top.reduce((sum, point) => sum + point[2], 0) / top.length
598
+ ];
599
+ appendOrientedQuad(surfacePositions, top[0], top[1], top[2], top[3], [0, 1, 0]);
600
+ appendOrientedQuad(surfacePositions, bottom[0], bottom[3], bottom[2], bottom[1], [0, -1, 0]);
601
+ appendInjectorBeltWall(surfacePositions, top[0], top[1], bottom[1], bottom[0], center);
602
+ appendInjectorBeltWall(sidePositions, top[1], top[2], bottom[2], bottom[1], center);
603
+ appendInjectorBeltWall(surfacePositions, top[2], top[3], bottom[3], bottom[2], center);
604
+ appendInjectorBeltWall(sidePositions, top[3], top[0], bottom[0], bottom[3], center);
605
+ const geometry = new state.three.BufferGeometry();
606
+ geometry.setAttribute("position", new state.three.Float32BufferAttribute([...surfacePositions, ...sidePositions], 3));
607
+ geometry.addGroup(0, surfacePositions.length / 3, 0);
608
+ geometry.addGroup(surfacePositions.length / 3, sidePositions.length / 3, 1);
609
+ geometry.computeVertexNormals();
610
+ geometry.computeBoundingBox();
611
+ geometry.computeBoundingSphere();
612
+ state.primitiveGeometryCache.set(key, geometry);
613
+ state.stats.geometries += 1;
614
+ return geometry;
615
+ }
616
+ function appendInjectorBeltWall(target, topA, topB, bottomB, bottomA, center) {
617
+ const middleX = (topA[0] + topB[0]) / 2;
618
+ const middleZ = (topA[2] + topB[2]) / 2;
619
+ let normalX = topB[2] - topA[2];
620
+ let normalZ = -(topB[0] - topA[0]);
621
+ if ((middleX - center[0]) * normalX + (middleZ - center[2]) * normalZ < 0) {
622
+ normalX = -normalX;
623
+ normalZ = -normalZ;
624
+ }
625
+ appendOrientedQuad(target, topA, topB, bottomB, bottomA, [normalX, 0, normalZ]);
626
+ }
447
627
  function readProceduralBelt(visual, state) {
448
628
  const properties = visual.properties;
449
629
  if (!properties || isFalse(properties.textOf("CenterVisible"))) {
@@ -468,12 +648,12 @@ function readProceduralBelt(visual, state) {
468
648
  const beltLength = numberChildOptional(properties, "BeltLength") ??
469
649
  (serializedLength === undefined ? undefined : serializedLength - startPadding - endPadding);
470
650
  const serializedWidth = numberChildOptional(properties, "Width");
471
- const beltWidth = numberChildOptional(properties, "BeltWidth") ?? serializedWidth;
651
+ const beltWidth = numberChildOptional(properties, "BeltWidth") ?? serializedWidth ?? DEFAULT_BELT_WIDTH;
472
652
  const effectiveLength = beltLength ?? 0;
473
- const effectiveWidth = (beltWidth ?? 0) - leftPadding - rightPadding;
653
+ const effectiveWidth = beltWidth - leftPadding - rightPadding;
474
654
  const centerDiameter = numberChildOptional(properties, "CenterDiameter") ??
475
655
  numberChildOptional(properties, "BeltDiameter") ??
476
- 0;
656
+ DEFAULT_BELT_DIAMETER;
477
657
  const useBeltCenterHeight = isTrue(properties.textOf("UseBeltCenterHeight"));
478
658
  const loopHeight = useBeltCenterHeight
479
659
  ? numberChild(properties, "BeltCenterHeight", centerDiameter)
@@ -538,6 +718,131 @@ function getProceduralBeltGeometry(belt, state) {
538
718
  state.stats.geometries += 1;
539
719
  return geometry;
540
720
  }
721
+ function createCurveBeltConveyorObject(visual, state) {
722
+ const belt = readProceduralCurveBelt(visual, state);
723
+ if (!belt) {
724
+ return undefined;
725
+ }
726
+ const geometry = getProceduralCurveBeltGeometry(belt, state);
727
+ const object = new state.three.Mesh(geometry, [
728
+ getMaterial(belt.surfaceMaterial, state),
729
+ getMaterial(belt.sideMaterial, state)
730
+ ]);
731
+ object.name = visual.displayName ?? visual.id ?? visual.typeName;
732
+ object.userData.demo3d = {
733
+ kind: "procedural-belt",
734
+ id: visual.id,
735
+ name: visual.displayName,
736
+ typeName: visual.typeName,
737
+ xmlPath: visual.xml.path,
738
+ innerRadius: belt.innerRadius,
739
+ width: belt.width,
740
+ loopHeight: belt.thickness,
741
+ angle: belt.angle
742
+ };
743
+ state.stats.meshes += 1;
744
+ state.stats.directVisuals += 1;
745
+ state.stats.proceduralBelts += 1;
746
+ return object;
747
+ }
748
+ function readProceduralCurveBelt(visual, state) {
749
+ const properties = visual.properties;
750
+ if (!properties || isFalse(properties.textOf("CenterVisible"))) {
751
+ return undefined;
752
+ }
753
+ const centerProfile = properties.child("CenterProfile");
754
+ if (centerProfile && (centerProfile.children.length > 0 || centerProfile.text.length > 0)) {
755
+ warn(state, {
756
+ code: "DEMO3D_THREE_UNSUPPORTED_BELT_PROFILE",
757
+ message: "Curve belt conveyors with a custom CenterProfile are not supported.",
758
+ sourceId: visual.id,
759
+ sourceType: visual.typeName,
760
+ xmlPath: visual.xml.path
761
+ });
762
+ return undefined;
763
+ }
764
+ const innerRadius = numberChild(properties, "InnerRadius", 0.5);
765
+ const width = numberChildOptional(properties, "BeltWidth")
766
+ ?? numberChildOptional(properties, "Width")
767
+ ?? DEFAULT_BELT_WIDTH;
768
+ const beltDiameter = numberChildOptional(properties, "CenterDiameter") ??
769
+ numberChildOptional(properties, "BeltDiameter") ??
770
+ DEFAULT_BELT_DIAMETER;
771
+ const thickness = isTrue(properties.textOf("UseBeltCenterHeight"))
772
+ ? numberChild(properties, "BeltCenterHeight", beltDiameter)
773
+ : beltDiameter;
774
+ const angle = degreesToRadians(numberChild(properties, "Angle", 90));
775
+ const stepAngle = Math.abs(degreesToRadians(numberChild(properties, "StepAngle", 15)));
776
+ if (innerRadius <= 0 || width <= 0 || thickness <= 0 || Math.abs(angle) <= 1e-6) {
777
+ warn(state, {
778
+ code: "DEMO3D_THREE_INVALID_BELT_DIMENSIONS",
779
+ message: `Curve belt conveyor dimensions must be positive and its angle non-zero (inner radius ${innerRadius}, width ${width}, height ${thickness}, angle ${numberChild(properties, "Angle", 90)}).`,
780
+ sourceId: visual.id,
781
+ sourceType: visual.typeName,
782
+ xmlPath: visual.xml.path
783
+ });
784
+ return undefined;
785
+ }
786
+ const surfaceMaterial = firstMaterial(properties.child("SurfaceMaterial") ?? properties);
787
+ const sideMaterial = firstMaterial(properties.child("SurfaceSideMaterial") ?? properties) ?? surfaceMaterial;
788
+ return {
789
+ innerRadius,
790
+ width,
791
+ thickness,
792
+ angle,
793
+ stepAngle: stepAngle > 1e-6 ? stepAngle : Math.PI / 12,
794
+ surfaceMaterial,
795
+ sideMaterial
796
+ };
797
+ }
798
+ function getProceduralCurveBeltGeometry(belt, state) {
799
+ const key = `curve-belt:${belt.innerRadius}:${belt.width}:${belt.thickness}:${belt.angle}:${belt.stepAngle}`;
800
+ const cached = state.primitiveGeometryCache.get(key);
801
+ if (cached) {
802
+ return cached;
803
+ }
804
+ const surfacePositions = [];
805
+ const sidePositions = [];
806
+ const outerRadius = belt.innerRadius + belt.width;
807
+ const segments = Math.max(4, Math.ceil(Math.abs(belt.angle) / belt.stepAngle));
808
+ const point = (radius, y, theta) => [
809
+ radius * Math.cos(theta),
810
+ y,
811
+ radius * Math.sin(theta)
812
+ ];
813
+ for (let segment = 0; segment < segments; segment += 1) {
814
+ const theta0 = belt.angle * (segment / segments);
815
+ const theta1 = belt.angle * ((segment + 1) / segments);
816
+ const middle = (theta0 + theta1) / 2;
817
+ const innerTop0 = point(belt.innerRadius, 0, theta0);
818
+ const innerTop1 = point(belt.innerRadius, 0, theta1);
819
+ const outerTop0 = point(outerRadius, 0, theta0);
820
+ const outerTop1 = point(outerRadius, 0, theta1);
821
+ const innerBottom0 = point(belt.innerRadius, -belt.thickness, theta0);
822
+ const innerBottom1 = point(belt.innerRadius, -belt.thickness, theta1);
823
+ const outerBottom0 = point(outerRadius, -belt.thickness, theta0);
824
+ const outerBottom1 = point(outerRadius, -belt.thickness, theta1);
825
+ appendOrientedQuad(surfacePositions, innerTop0, outerTop0, outerTop1, innerTop1, [0, 1, 0]);
826
+ appendOrientedQuad(surfacePositions, innerBottom0, innerBottom1, outerBottom1, outerBottom0, [0, -1, 0]);
827
+ appendOrientedQuad(sidePositions, outerBottom0, outerBottom1, outerTop1, outerTop0, [Math.cos(middle), 0, Math.sin(middle)]);
828
+ appendOrientedQuad(sidePositions, innerBottom0, innerTop0, innerTop1, innerBottom1, [-Math.cos(middle), 0, -Math.sin(middle)]);
829
+ }
830
+ const direction = Math.sign(belt.angle) || 1;
831
+ const start = 0;
832
+ const end = belt.angle;
833
+ appendOrientedQuad(surfacePositions, point(belt.innerRadius, -belt.thickness, start), point(outerRadius, -belt.thickness, start), point(outerRadius, 0, start), point(belt.innerRadius, 0, start), [direction * Math.sin(start), 0, -direction * Math.cos(start)]);
834
+ appendOrientedQuad(surfacePositions, point(belt.innerRadius, -belt.thickness, end), point(belt.innerRadius, 0, end), point(outerRadius, 0, end), point(outerRadius, -belt.thickness, end), [-direction * Math.sin(end), 0, direction * Math.cos(end)]);
835
+ const geometry = new state.three.BufferGeometry();
836
+ geometry.setAttribute("position", new state.three.Float32BufferAttribute([...surfacePositions, ...sidePositions], 3));
837
+ geometry.addGroup(0, surfacePositions.length / 3, 0);
838
+ geometry.addGroup(surfacePositions.length / 3, sidePositions.length / 3, 1);
839
+ geometry.computeVertexNormals();
840
+ geometry.computeBoundingBox();
841
+ geometry.computeBoundingSphere();
842
+ state.primitiveGeometryCache.set(key, geometry);
843
+ state.stats.geometries += 1;
844
+ return geometry;
845
+ }
541
846
  function createRectangularBeltGeometry(belt, state) {
542
847
  const geometry = new state.three.BoxGeometry(belt.length, belt.thickness, belt.width);
543
848
  geometry.translate(belt.xOffset + belt.length / 2, -belt.thickness / 2, belt.zOffset);
@@ -582,6 +887,117 @@ function createRoundedBeltGeometry(belt, state) {
582
887
  }
583
888
  return geometry;
584
889
  }
890
+ function createRackObject(visual, state) {
891
+ const properties = visual.properties;
892
+ if (!properties) {
893
+ return undefined;
894
+ }
895
+ const frameHeight = numberChild(properties, "FrameHeight", 0);
896
+ const uprightWidth = numberChild(properties, "UprightWidth", 0.05);
897
+ const uprightDepth = numberChild(properties, "UprightDepth", 0.05);
898
+ const frameDepth = numberChildOptional(properties, "FrameDepth") ?? numberChild(properties, "BayDepth", 0);
899
+ const strutWidth = numberChild(properties, "StrutWidth", 0.05);
900
+ if (frameHeight <= 0 || frameDepth <= 0 || uprightWidth <= 0 || uprightDepth <= 0 || strutWidth <= 0) {
901
+ warn(state, {
902
+ code: "DEMO3D_THREE_INVALID_RACK_DIMENSIONS",
903
+ message: `Rack dimensions must be positive (height ${frameHeight}, depth ${frameDepth}, upright ${uprightWidth} x ${uprightDepth}, strut ${strutWidth}).`,
904
+ sourceId: visual.id,
905
+ sourceType: visual.typeName,
906
+ xmlPath: visual.xml.path
907
+ });
908
+ return undefined;
909
+ }
910
+ const numBays = Math.max(1, Math.round(numberChild(properties, "NumBays", 1)));
911
+ const bayWidth = Math.max(0, numberChild(properties, "BayWidth", 0));
912
+ const framePitch = bayWidth + uprightWidth + Math.max(0, numberChild(properties, "MinFrameGap", 0));
913
+ const framePositions = [0];
914
+ if (!isFalse(properties.child("MiddleFrames")?.textOf("Visible"))) {
915
+ for (let bay = 1; bay < numBays; bay += 1) {
916
+ framePositions.push(bay * framePitch);
917
+ }
918
+ }
919
+ if (!isFalse(properties.child("LastFrame")?.textOf("Visible")) && framePitch > 0) {
920
+ framePositions.push(numBays * framePitch);
921
+ }
922
+ const initialStrutHeight = clamp(numberChild(properties, "InitialStrutHeight", strutWidth / 2), 0, frameHeight);
923
+ const strutSpanHeight = numberChild(properties, "StrutSpanHeight", frameHeight);
924
+ const strutHeights = [initialStrutHeight];
925
+ if (strutSpanHeight > 0) {
926
+ for (let height = initialStrutHeight + strutSpanHeight; height < frameHeight - 1e-6; height += strutSpanHeight) {
927
+ strutHeights.push(height);
928
+ }
929
+ }
930
+ const extensionHeight = clamp(frameHeight - Math.max(0, numberChild(properties, "ExtensionStrutOffset", strutWidth / 2)), 0, frameHeight);
931
+ if (!strutHeights.some((height) => Math.abs(height - extensionHeight) < 1e-6)) {
932
+ strutHeights.push(extensionHeight);
933
+ }
934
+ strutHeights.sort((left, right) => left - right);
935
+ const uprightMaterial = getColorMaterial(demo3dColorValue(properties.child("UprightColor"), 0xffd3d3d3 | 0), state);
936
+ const strutMaterial = getColorMaterial(demo3dColorValue(properties.child("StrutColor"), 0xffd3d3d3 | 0), state);
937
+ const uprightGeometry = cachedBoxGeometry(uprightWidth, frameHeight, uprightDepth, state);
938
+ const strutGeometry = cachedBoxGeometry(strutWidth, strutWidth, frameDepth, state);
939
+ const group = new state.three.Group();
940
+ group.name = `${visual.displayName ?? visual.id ?? "Rack"} generated geometry`;
941
+ for (const frameX of framePositions) {
942
+ for (const z of [-frameDepth / 2, frameDepth / 2]) {
943
+ const upright = new state.three.Mesh(uprightGeometry, uprightMaterial);
944
+ upright.name = "Rack upright";
945
+ upright.position.set(frameX, frameHeight / 2, z);
946
+ upright.userData.demo3d = { kind: "procedural-rack-upright", frameX };
947
+ group.add(upright);
948
+ state.stats.meshes += 1;
949
+ }
950
+ for (const height of strutHeights) {
951
+ const strut = new state.three.Mesh(strutGeometry, strutMaterial);
952
+ strut.name = "Rack strut";
953
+ strut.position.set(frameX, height, 0);
954
+ strut.userData.demo3d = { kind: "procedural-rack-strut", frameX, height };
955
+ group.add(strut);
956
+ state.stats.meshes += 1;
957
+ }
958
+ }
959
+ group.userData.demo3d = {
960
+ kind: "procedural-rack",
961
+ id: visual.id,
962
+ name: visual.displayName,
963
+ typeName: visual.typeName,
964
+ xmlPath: visual.xml.path,
965
+ frameHeight,
966
+ frameDepth,
967
+ framePositions,
968
+ strutHeights
969
+ };
970
+ state.stats.groups += 1;
971
+ state.stats.directVisuals += 1;
972
+ state.stats.proceduralRacks += 1;
973
+ return group;
974
+ }
975
+ function demo3dColorValue(container, fallback) {
976
+ const numeric = container?.text.trim();
977
+ if (numeric) {
978
+ const parsed = Number(numeric);
979
+ if (Number.isFinite(parsed)) {
980
+ return parsed;
981
+ }
982
+ }
983
+ switch (container?.textOf("name")?.trim().toLowerCase()) {
984
+ case "black":
985
+ return 0xff000000 | 0;
986
+ case "darkgray":
987
+ case "darkgrey":
988
+ return 0xffa9a9a9 | 0;
989
+ case "gray":
990
+ case "grey":
991
+ return 0xff808080 | 0;
992
+ case "lightgray":
993
+ case "lightgrey":
994
+ return 0xffd3d3d3 | 0;
995
+ case "white":
996
+ return 0xffffffff | 0;
997
+ default:
998
+ return fallback;
999
+ }
1000
+ }
585
1001
  function createSupportStandObject(visual, parentVisual, state) {
586
1002
  const stand = visual instanceof Demo3DSupportStand
587
1003
  ? visual
@@ -591,7 +1007,13 @@ function createSupportStandObject(visual, parentVisual, state) {
591
1007
  const footProfile = properties?.footProfile;
592
1008
  const floorPlateProfile = properties?.floorPlateProfile;
593
1009
  const crossBraceProfile = properties?.crossBraceProfile;
594
- if (!properties || !legProfile || !crossBraceProfile) {
1010
+ if (!properties) {
1011
+ return undefined;
1012
+ }
1013
+ if (!legProfile && !crossBraceProfile) {
1014
+ return createDefaultSupportStandObject(visual, parentVisual, properties, state);
1015
+ }
1016
+ if (!legProfile || !crossBraceProfile) {
595
1017
  warn(state, {
596
1018
  code: "DEMO3D_THREE_UNSUPPORTED_SUPPORT_STAND_PROFILE",
597
1019
  message: "Support stand is missing its leg or cross-brace extrusion profile.",
@@ -659,6 +1081,84 @@ function createSupportStandObject(visual, parentVisual, state) {
659
1081
  state.stats.proceduralSupportStands += 1;
660
1082
  return group;
661
1083
  }
1084
+ const DEFAULT_CONVEYOR_HEIGHT = 0.83;
1085
+ const DEFAULT_SUPPORT_LEG_SIZE = 0.04;
1086
+ const DEFAULT_SUPPORT_FOOT_SIZE = 0.06;
1087
+ const DEFAULT_SUPPORT_FLOOR_PLATE_SIZE = 0.1;
1088
+ function createDefaultSupportStandObject(visual, parentVisual, properties, state) {
1089
+ const parentWidth = numberChildOptional(parentVisual?.properties, "RollerWidth") ??
1090
+ numberChildOptional(parentVisual?.properties, "BeltWidth") ??
1091
+ numberChildOptional(parentVisual?.properties, "Width") ??
1092
+ DEFAULT_BELT_WIDTH;
1093
+ const conveyorOffset = properties.conveyorOffset;
1094
+ const sideExtension = Math.abs(conveyorOffset[2] ?? 0);
1095
+ const span = Math.max(parentWidth + sideExtension * 2, 0.1);
1096
+ const top = conveyorOffset[1] ?? 0;
1097
+ const x = conveyorOffset[0] ?? 0;
1098
+ const floorY = numberChild(properties.xml, "FloorY", 0);
1099
+ const explicitStandHeight = numberChildOptional(properties.xml, "StandHeight") ??
1100
+ numberChildOptional(properties.xml, "SupportHeight") ??
1101
+ numberChildOptional(properties.xml, "Height");
1102
+ const explicitLegHeight = numberChildOptional(properties.xml, "LegHeight");
1103
+ const conveyorHeight = numberChildOptional(parentVisual?.properties, "ConveyorHeight") ?? DEFAULT_CONVEYOR_HEIGHT;
1104
+ const componentHeight = explicitLegHeight === undefined
1105
+ ? undefined
1106
+ : explicitLegHeight + properties.footHeight + properties.floorPlateHeight;
1107
+ const supportHeight = Math.max(explicitStandHeight ?? componentHeight ?? conveyorHeight + top - floorY, properties.footHeight + properties.floorPlateHeight + 0.001);
1108
+ const bottom = top - supportHeight;
1109
+ const floorPlateHeight = clamp(properties.floorPlateHeight, 0, supportHeight);
1110
+ const footHeight = clamp(properties.footHeight, 0, supportHeight - floorPlateHeight);
1111
+ const legHeight = Math.max(supportHeight - floorPlateHeight - footHeight, 0.001);
1112
+ const braces = [...properties.crossBraceHeights]
1113
+ .filter((height) => height > 0 && height < supportHeight)
1114
+ .sort((left, right) => left - right);
1115
+ const group = new state.three.Group();
1116
+ group.name = `${visual.displayName ?? visual.id ?? "Support stand"} default geometry`;
1117
+ const legMaterial = supportComponentMaterial(visual.properties?.child("LegMaterial"), properties.legMaterial, state);
1118
+ const footMaterial = supportComponentMaterial(visual.properties?.child("FootMaterial"), properties.footMaterial, state);
1119
+ const floorPlateMaterial = supportComponentMaterial(visual.properties?.child("FloorPlateMaterial"), properties.floorPlateMaterial, state);
1120
+ const braceMaterial = supportComponentMaterial(visual.properties?.child("CrossBraceMaterial"), properties.crossBraceMaterial, state);
1121
+ for (const z of [-span / 2, span / 2]) {
1122
+ addDefaultSupportBox(group, "leg", DEFAULT_SUPPORT_LEG_SIZE, legHeight, DEFAULT_SUPPORT_LEG_SIZE, [x, top - legHeight / 2, z], legMaterial, state);
1123
+ if (footHeight > 0) {
1124
+ addDefaultSupportBox(group, "foot", DEFAULT_SUPPORT_FOOT_SIZE, footHeight, DEFAULT_SUPPORT_FOOT_SIZE, [x, bottom + floorPlateHeight + footHeight / 2, z], footMaterial, state);
1125
+ }
1126
+ if (floorPlateHeight > 0) {
1127
+ addDefaultSupportBox(group, "floor plate", DEFAULT_SUPPORT_FLOOR_PLATE_SIZE, floorPlateHeight, DEFAULT_SUPPORT_FLOOR_PLATE_SIZE, [x, bottom + floorPlateHeight / 2, z], floorPlateMaterial, state);
1128
+ }
1129
+ }
1130
+ for (const height of braces) {
1131
+ addDefaultSupportBox(group, "cross brace", DEFAULT_SUPPORT_LEG_SIZE, DEFAULT_SUPPORT_LEG_SIZE, span, [x, bottom + height, 0], braceMaterial, state);
1132
+ }
1133
+ addDefaultSupportBox(group, "top brace", DEFAULT_SUPPORT_LEG_SIZE, DEFAULT_SUPPORT_LEG_SIZE, span, [x, top - DEFAULT_SUPPORT_LEG_SIZE / 2, 0], braceMaterial, state);
1134
+ group.userData.demo3d = {
1135
+ kind: "procedural-support-stand",
1136
+ id: visual.id,
1137
+ name: visual.displayName,
1138
+ typeName: visual.typeName,
1139
+ xmlPath: visual.xml.path,
1140
+ supportHeight,
1141
+ span,
1142
+ braceHeights: braces,
1143
+ approximate: true,
1144
+ defaultProfiles: true
1145
+ };
1146
+ state.stats.groups += 1;
1147
+ state.stats.directVisuals += 1;
1148
+ state.stats.proceduralSupportStands += 1;
1149
+ return group;
1150
+ }
1151
+ function supportComponentMaterial(container, fallbackColor, state) {
1152
+ return materialFromContainer(container, state) ?? getColorMaterial(fallbackColor, state);
1153
+ }
1154
+ function addDefaultSupportBox(group, component, width, height, depth, position, material, state) {
1155
+ const mesh = new state.three.Mesh(cachedBoxGeometry(width, height, depth, state), material);
1156
+ mesh.name = `Default support ${component}`;
1157
+ mesh.position.set(position[0], position[1], position[2]);
1158
+ mesh.userData.demo3d = { kind: "support-stand-component", component, approximate: true };
1159
+ group.add(mesh);
1160
+ state.stats.meshes += 1;
1161
+ }
662
1162
  function addSupportProfileMeshes(group, profile, axis, length, position, fallbackColor, component, state) {
663
1163
  profile.polygons.forEach((polygon, index) => {
664
1164
  if (polygon.points.length < 3) {
@@ -937,6 +1437,25 @@ function addProceduralRollers(group, visual, state) {
937
1437
  const material = materialFromContainer(properties.child("SurfaceMaterial") ?? properties, state)
938
1438
  ?? getColorMaterial(numberChildOptional(properties, "RollerColor"), state);
939
1439
  const geometry = cachedRollerGeometry(radius, width, state);
1440
+ if (visual.typeName === "e3d:DiverterRollerConveyor") {
1441
+ const length = numberChild(properties, "Length", 1);
1442
+ const pitch = Math.max(numberChild(properties, "RollerPitch", diameter * 1.2), diameter);
1443
+ const count = Math.max(1, Math.round(numberChildOptional(properties, "RollerCount") ?? length / pitch));
1444
+ const across = Math.max(1, Math.round(numberChild(properties, "NumRollersAcrossWidth", 1)));
1445
+ const totalWidth = numberChild(properties, "Width", width);
1446
+ for (let index = 0; index < count; index += 1) {
1447
+ for (let acrossIndex = 0; acrossIndex < across; acrossIndex += 1) {
1448
+ const roller = new state.three.Mesh(geometry, material);
1449
+ roller.name = `Generated divert roller ${index + 1}.${acrossIndex + 1}`;
1450
+ roller.position.set(((index + 0.5) / count) * length, 0, ((acrossIndex + 0.5) / across - 0.5) * totalWidth);
1451
+ roller.userData.demo3d = { kind: "procedural-roller", index, acrossIndex };
1452
+ group.add(roller);
1453
+ state.stats.meshes += 1;
1454
+ state.stats.proceduralRollers += 1;
1455
+ }
1456
+ }
1457
+ return;
1458
+ }
940
1459
  if (visual.typeName === "e3d:CurveRollerConveyor") {
941
1460
  const count = Math.max(1, Math.round(numberChild(properties, "RollerCount", 1)));
942
1461
  const innerRadius = numberChild(properties, "InnerRadius", 0.5);
@@ -1050,6 +1569,7 @@ function cachedBoxGeometry(width, height, depth, state) {
1050
1569
  function isRollerConveyorType(typeName) {
1051
1570
  return (typeName === "e3d:StraightRollerConveyor" ||
1052
1571
  typeName === "e3d:CurveRollerConveyor" ||
1572
+ typeName === "e3d:DiverterRollerConveyor" ||
1053
1573
  typeName === "e3d:InjectorRollerConveyor");
1054
1574
  }
1055
1575
  function createImportedImageObject(visual, state) {
@@ -1107,6 +1627,7 @@ function getDirectVisualGeometry(visual, state) {
1107
1627
  properties.textOf("Length") ?? "",
1108
1628
  properties.textOf("Radius") ?? "",
1109
1629
  properties.textOf("StartAngle") ?? "",
1630
+ properties.textOf("Thickness") ?? "",
1110
1631
  properties.textOf("Width") ?? ""
1111
1632
  ].join("|")}`;
1112
1633
  const cached = state.primitiveGeometryCache.get(key);
@@ -1114,9 +1635,12 @@ function getDirectVisualGeometry(visual, state) {
1114
1635
  return cached;
1115
1636
  }
1116
1637
  let geometry;
1117
- if (visual.typeName === "e3d:BoxVisual" || visual.typeName === "e3d:BoxTubeVisual") {
1638
+ if (visual.typeName === "e3d:BoxVisual") {
1118
1639
  geometry = new state.three.BoxGeometry(numberChild(properties, "Width", 1), numberChild(properties, "Height", 1), numberChild(properties, "Depth", 1));
1119
1640
  }
1641
+ else if (visual.typeName === "e3d:BoxTubeVisual") {
1642
+ geometry = createBoxTubeGeometry(numberChild(properties, "Width", 0.25), numberChild(properties, "Height", 1), numberChild(properties, "Depth", 0.25), numberChild(properties, "Thickness", 0.015), state);
1643
+ }
1120
1644
  else if (visual.typeName === "e3d:CylinderVisual") {
1121
1645
  geometry = new state.three.CylinderGeometry(numberChild(properties, "Radius", 0.5), numberChild(properties, "Radius", 0.5), numberChild(properties, "Length", 1), 32, 1, false, degreesToRadians(numberChild(properties, "StartAngle", 0)), degreesToRadians(numberChild(properties, "Angle", 360)));
1122
1646
  }
@@ -1133,6 +1657,37 @@ function getDirectVisualGeometry(visual, state) {
1133
1657
  state.stats.geometries += 1;
1134
1658
  return geometry;
1135
1659
  }
1660
+ function createBoxTubeGeometry(width, height, depth, thickness, state) {
1661
+ const halfWidth = Math.max(width, 0) / 2;
1662
+ const halfDepth = Math.max(depth, 0) / 2;
1663
+ const edge = clamp(thickness, 0, Math.min(halfWidth, halfDepth));
1664
+ const shape = new state.three.Shape();
1665
+ shape.moveTo(-halfWidth, -halfDepth);
1666
+ shape.lineTo(-halfWidth, halfDepth);
1667
+ shape.lineTo(halfWidth, halfDepth);
1668
+ shape.lineTo(halfWidth, -halfDepth);
1669
+ shape.closePath();
1670
+ const innerHalfWidth = halfWidth - edge;
1671
+ const innerHalfDepth = halfDepth - edge;
1672
+ if (innerHalfWidth > 0 && innerHalfDepth > 0) {
1673
+ const hole = new state.three.Path();
1674
+ hole.moveTo(-innerHalfWidth, -innerHalfDepth);
1675
+ hole.lineTo(innerHalfWidth, -innerHalfDepth);
1676
+ hole.lineTo(innerHalfWidth, innerHalfDepth);
1677
+ hole.lineTo(-innerHalfWidth, innerHalfDepth);
1678
+ hole.closePath();
1679
+ shape.holes.push(hole);
1680
+ }
1681
+ const geometry = new state.three.ExtrudeGeometry(shape, {
1682
+ depth: Math.max(height, 0),
1683
+ steps: 1,
1684
+ bevelEnabled: false
1685
+ });
1686
+ // ExtrudeGeometry uses Z for its extrusion. Demo3D box tubes extrude along Y.
1687
+ geometry.rotateX(-Math.PI / 2);
1688
+ geometry.translate(0, -Math.max(height, 0) / 2, 0);
1689
+ return geometry;
1690
+ }
1136
1691
  function createWedgeGeometry(width, height, length, state) {
1137
1692
  const x = width / 2;
1138
1693
  const y = height / 2;
@@ -1199,8 +1754,12 @@ function getDrawingBlockGeometry(blockId, state) {
1199
1754
  state.stats.geometries += 1;
1200
1755
  return geometry;
1201
1756
  }
1202
- function createMeshObject(meshId, source, material, state) {
1757
+ function createMeshObject(meshId, source, materialSelection, state) {
1203
1758
  const mesh = state.meshById.get(meshId);
1759
+ const materials = Array.isArray(materialSelection)
1760
+ ? materialSelection
1761
+ : materialSelection ? [materialSelection] : [];
1762
+ const primaryMaterial = materials[0];
1204
1763
  const sourceId = source instanceof Demo3DVisual ? source.id : source.textOf("Id");
1205
1764
  const sourceType = source instanceof Demo3DVisual ? source.typeName : source.xsiType ?? source.localName;
1206
1765
  const xmlPath = source instanceof Demo3DVisual ? source.xml.path : source.path;
@@ -1213,20 +1772,24 @@ function createMeshObject(meshId, source, material, state) {
1213
1772
  xmlPath
1214
1773
  });
1215
1774
  return state.options.showPlaceholders === true
1216
- ? createPlaceholderMesh(sourceId ?? meshId, meshId, material, state)
1775
+ ? createPlaceholderMesh(sourceId ?? meshId, meshId, primaryMaterial, state)
1217
1776
  : undefined;
1218
1777
  }
1219
1778
  const geometry = getGeometry(mesh, state);
1220
1779
  if (!geometry) {
1221
1780
  return state.options.showPlaceholders === true
1222
- ? createPlaceholderMesh(sourceId ?? meshId, meshId, material, state)
1781
+ ? createPlaceholderMesh(sourceId ?? meshId, meshId, primaryMaterial, state)
1223
1782
  : undefined;
1224
1783
  }
1225
- const object = new state.three.Mesh(geometry, getMaterial(material, state));
1784
+ const threeMaterial = materials.length > 1
1785
+ ? materials.map((material) => getMaterial(material, state))
1786
+ : getMaterial(primaryMaterial, state);
1787
+ const object = new state.three.Mesh(geometry, threeMaterial);
1226
1788
  object.name = source instanceof Demo3DVisual ? source.displayName ?? meshId : meshId;
1227
1789
  object.userData.demo3d = {
1228
1790
  kind: "mesh",
1229
1791
  meshId,
1792
+ materialSlots: materials.length,
1230
1793
  sourceId,
1231
1794
  sourceType,
1232
1795
  xmlPath
@@ -1720,8 +2283,7 @@ function createSerializedRenderableGroup(state) {
1720
2283
  }
1721
2284
  const meshIds = findMeshReferenceIds(renderable);
1722
2285
  for (const meshId of meshIds) {
1723
- const material = firstMaterial(renderable);
1724
- const mesh = createMeshObject(meshId, renderable, material, state);
2286
+ const mesh = createMeshObject(meshId, renderable, renderableMaterials(renderable), state);
1725
2287
  if (!mesh) {
1726
2288
  continue;
1727
2289
  }
@@ -1921,6 +2483,23 @@ function findVisualMeshReferenceIds(root) {
1921
2483
  }
1922
2484
  return [...ids];
1923
2485
  }
2486
+ function renderableMaterials(renderable) {
2487
+ const materials = [];
2488
+ const materialProperties = renderable.child("MaterialProperties");
2489
+ for (const entry of materialProperties?.children ?? []) {
2490
+ const material = firstMaterial(entry);
2491
+ if (material) {
2492
+ materials.push(material);
2493
+ }
2494
+ }
2495
+ if (materials.length === 0) {
2496
+ const material = firstMaterial(renderable);
2497
+ if (material) {
2498
+ materials.push(material);
2499
+ }
2500
+ }
2501
+ return materials;
2502
+ }
1924
2503
  function firstMaterial(root) {
1925
2504
  let found;
1926
2505
  visit(root, (node) => {
@@ -2004,7 +2583,9 @@ function applyDemo3DTransform(object, transformText) {
2004
2583
  return;
2005
2584
  }
2006
2585
  object.position.set(values[0] ?? 0, values[1] ?? 0, -(values[2] ?? 0));
2007
- object.rotation.set(-(values[3] ?? 0), -(values[4] ?? 0), values[5] ?? 0);
2586
+ // Demo3D rotation triples are pitch (X), yaw (Y), and roll (Z). Its
2587
+ // RotationYawPitchRoll matrix corresponds to Three's intrinsic YXZ order.
2588
+ object.rotation.set(-(values[3] ?? 0), -(values[4] ?? 0), values[5] ?? 0, "YXZ");
2008
2589
  }
2009
2590
  function convertTriangleGeometryToThreeCoordinates(geometry) {
2010
2591
  geometry.scale(1, 1, -1);