@operato/scene-storage 10.0.0-beta.43 → 10.0.0-beta.44

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
@@ -2,7 +2,7 @@
2
2
  "name": "@operato/scene-storage",
3
3
  "description": "Storage-domain components for things-scene (smart factory / logistics) — pallet, box, parcel; AS/RS and shelves planned.",
4
4
  "author": "heartyoh",
5
- "version": "10.0.0-beta.43",
5
+ "version": "10.0.0-beta.44",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "module": "dist/index.js",
@@ -45,5 +45,5 @@
45
45
  "typescript": "^5.0.4"
46
46
  },
47
47
  "prettier": "@hatiolab/prettier-config",
48
- "gitHead": "6e92206ada64b1b0d4e6a4f73ee34ee99e87bd57"
48
+ "gitHead": "c9abb002847f21c028102ba5eb489fb57b2dd95e"
49
49
  }
package/src/box-3d.ts CHANGED
@@ -16,6 +16,55 @@ import * as THREE from 'three'
16
16
  import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
17
17
  import { RealObjectGroup } from '@hatiolab/things-scene'
18
18
 
19
+ // ── Material cache by bodyColor (shared across all Box3D instances) ──
20
+ const woodBodyMaterials = new Map<string, THREE.MeshStandardMaterial>()
21
+ const woodPostMaterials = new Map<string, THREE.MeshStandardMaterial>()
22
+ const plasticToteMaterials = new Map<string, THREE.MeshStandardMaterial>()
23
+ const plasticLipMaterials = new Map<string, THREE.MeshStandardMaterial>()
24
+
25
+ function getWoodBodyMaterial(bodyColor: string): THREE.MeshStandardMaterial {
26
+ let m = woodBodyMaterials.get(bodyColor)
27
+ if (!m) {
28
+ m = new THREE.MeshStandardMaterial({ color: bodyColor, metalness: 0, roughness: 0.85 })
29
+ woodBodyMaterials.set(bodyColor, m)
30
+ }
31
+ return m
32
+ }
33
+
34
+ function getWoodPostMaterial(bodyColor: string): THREE.MeshStandardMaterial {
35
+ let m = woodPostMaterials.get(bodyColor)
36
+ if (!m) {
37
+ const tint = new THREE.Color(bodyColor).multiplyScalar(0.8)
38
+ m = new THREE.MeshStandardMaterial({ color: tint, metalness: 0, roughness: 0.9 })
39
+ woodPostMaterials.set(bodyColor, m)
40
+ }
41
+ return m
42
+ }
43
+
44
+ function getPlasticToteMaterial(bodyColor: string): THREE.MeshStandardMaterial {
45
+ let m = plasticToteMaterials.get(bodyColor)
46
+ if (!m) {
47
+ m = new THREE.MeshStandardMaterial({ color: bodyColor, metalness: 0.05, roughness: 0.55 })
48
+ plasticToteMaterials.set(bodyColor, m)
49
+ }
50
+ return m
51
+ }
52
+
53
+ function getPlasticLipMaterial(bodyColor: string): THREE.MeshStandardMaterial {
54
+ let m = plasticLipMaterials.get(bodyColor)
55
+ if (!m) {
56
+ const tint = new THREE.Color(bodyColor).multiplyScalar(0.85)
57
+ m = new THREE.MeshStandardMaterial({ color: tint, metalness: 0.05, roughness: 0.55 })
58
+ plasticLipMaterials.set(bodyColor, m)
59
+ }
60
+ return m
61
+ }
62
+
63
+ // ── Geometry cache by size (shared across all Box3D instances) ──
64
+ // Floor geo는 local 좌표 + mesh position 으로 두어 mesh 별 position 만 다름.
65
+ const woodGeoCache = new Map<string, { posts: THREE.BufferGeometry; slats: THREE.BufferGeometry; floor: THREE.BufferGeometry; floorY: number }>()
66
+ const plasticGeoCache = new Map<string, { walls: THREE.BufferGeometry; lip: THREE.BufferGeometry; floor: THREE.BufferGeometry; floorY: number }>()
67
+
19
68
  export class Box3D extends RealObjectGroup {
20
69
  build() {
21
70
  super.build()
@@ -33,6 +82,31 @@ export class Box3D extends RealObjectGroup {
33
82
 
34
83
  /** Wood crate — visible slats, 4 corner posts, open top. */
35
84
  private buildWoodCrate(width: number, height: number, depth: number, bodyColor: string) {
85
+ const { posts, slats, floor, floorY } = this.getWoodGeometries(width, height, depth)
86
+
87
+ const woodMaterial = getWoodBodyMaterial(bodyColor)
88
+ const postMaterial = getWoodPostMaterial(bodyColor)
89
+
90
+ const postMesh = new THREE.Mesh(posts, postMaterial)
91
+ postMesh.castShadow = true
92
+ this.object3d.add(postMesh)
93
+
94
+ const slatMesh = new THREE.Mesh(slats, woodMaterial)
95
+ slatMesh.castShadow = true
96
+ slatMesh.receiveShadow = true
97
+ this.object3d.add(slatMesh)
98
+
99
+ const floorMesh = new THREE.Mesh(floor, woodMaterial)
100
+ floorMesh.position.set(0, floorY, 0)
101
+ floorMesh.receiveShadow = true
102
+ this.object3d.add(floorMesh)
103
+ }
104
+
105
+ private getWoodGeometries(width: number, height: number, depth: number) {
106
+ const key = `${width}|${height}|${depth}`
107
+ let cached = woodGeoCache.get(key)
108
+ if (cached) return cached
109
+
36
110
  const baseY = -depth / 2
37
111
  const wallThickness = Math.min(width, height) * 0.04
38
112
  const postW = wallThickness * 1.6
@@ -40,19 +114,6 @@ export class Box3D extends RealObjectGroup {
40
114
  const slatGap = slatH * 0.6
41
115
  const floorH = depth * 0.05
42
116
 
43
- const woodMaterial = new THREE.MeshStandardMaterial({
44
- color: bodyColor,
45
- metalness: 0,
46
- roughness: 0.85
47
- })
48
- const postColor = new THREE.Color(bodyColor).multiplyScalar(0.8)
49
- const postMaterial = new THREE.MeshStandardMaterial({
50
- color: postColor,
51
- metalness: 0,
52
- roughness: 0.9
53
- })
54
-
55
- // ── 4 corner posts ───────────────────────────────────────────────
56
117
  const postGeos: THREE.BufferGeometry[] = []
57
118
  for (const xSign of [-1, 1]) {
58
119
  for (const zSign of [-1, 1]) {
@@ -65,108 +126,100 @@ export class Box3D extends RealObjectGroup {
65
126
  postGeos.push(post)
66
127
  }
67
128
  }
68
- const postMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(postGeos), postMaterial)
69
- postMesh.castShadow = true
70
- this.object3d.add(postMesh)
129
+ const posts = BufferGeometryUtils.mergeGeometries(postGeos)
71
130
 
72
- // ── Slatted walls (horizontal slats with gaps) ───────────────────
73
131
  const slatRowCount = Math.max(2, Math.floor((depth - floorH) / (slatH + slatGap)))
74
132
  const slatGeos: THREE.BufferGeometry[] = []
75
-
76
133
  for (let row = 0; row < slatRowCount; row++) {
77
134
  const y = baseY + floorH + slatGap + row * (slatH + slatGap) + slatH / 2
78
- // Long walls (front / back)
79
135
  for (const zSign of [-1, 1]) {
80
136
  const slat = new THREE.BoxGeometry(width - postW * 2, slatH, wallThickness)
81
137
  slat.translate(0, y, zSign * (height / 2 - wallThickness / 2))
82
138
  slatGeos.push(slat)
83
139
  }
84
- // Short walls (left / right)
85
140
  for (const xSign of [-1, 1]) {
86
141
  const slat = new THREE.BoxGeometry(wallThickness, slatH, height - postW * 2)
87
142
  slat.translate(xSign * (width / 2 - wallThickness / 2), y, 0)
88
143
  slatGeos.push(slat)
89
144
  }
90
145
  }
91
- const slatMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(slatGeos), woodMaterial)
92
- slatMesh.castShadow = true
93
- slatMesh.receiveShadow = true
94
- this.object3d.add(slatMesh)
146
+ const slats = BufferGeometryUtils.mergeGeometries(slatGeos)
95
147
 
96
- // ── Floor (bottom panel) ─────────────────────────────────────────
97
- const floorGeo = new THREE.BoxGeometry(width - postW * 2, floorH, height - postW * 2)
98
- const floorMesh = new THREE.Mesh(floorGeo, woodMaterial)
99
- floorMesh.position.set(0, baseY + floorH / 2, 0)
100
- floorMesh.receiveShadow = true
101
- this.object3d.add(floorMesh)
148
+ const floor = new THREE.BoxGeometry(width - postW * 2, floorH, height - postW * 2)
149
+ const floorY = baseY + floorH / 2
150
+
151
+ cached = { posts, slats, floor, floorY }
152
+ woodGeoCache.set(key, cached)
153
+ return cached
102
154
  }
103
155
 
104
156
  /** Plastic tote — solid molded walls + top stackable lip. */
105
157
  private buildPlasticTote(width: number, height: number, depth: number, bodyColor: string) {
158
+ const { walls, lip, floor, floorY } = this.getPlasticGeometries(width, height, depth)
159
+
160
+ const totMaterial = getPlasticToteMaterial(bodyColor)
161
+ const lipMaterial = getPlasticLipMaterial(bodyColor)
162
+
163
+ const wallMesh = new THREE.Mesh(walls, totMaterial)
164
+ wallMesh.castShadow = true
165
+ wallMesh.receiveShadow = true
166
+ this.object3d.add(wallMesh)
167
+
168
+ const lipMesh = new THREE.Mesh(lip, lipMaterial)
169
+ lipMesh.castShadow = true
170
+ this.object3d.add(lipMesh)
171
+
172
+ const floorMesh = new THREE.Mesh(floor, totMaterial)
173
+ floorMesh.position.set(0, floorY, 0)
174
+ floorMesh.receiveShadow = true
175
+ this.object3d.add(floorMesh)
176
+ }
177
+
178
+ private getPlasticGeometries(width: number, height: number, depth: number) {
179
+ const key = `${width}|${height}|${depth}`
180
+ let cached = plasticGeoCache.get(key)
181
+ if (cached) return cached
182
+
106
183
  const baseY = -depth / 2
107
184
  const wallThickness = Math.min(width, height) * 0.05
108
185
  const lipH = depth * 0.06
109
186
  const floorH = depth * 0.06
110
187
 
111
- const totMaterial = new THREE.MeshStandardMaterial({
112
- color: bodyColor,
113
- metalness: 0.05,
114
- roughness: 0.55
115
- })
116
- const lipColor = new THREE.Color(bodyColor).multiplyScalar(0.85)
117
- const lipMaterial = new THREE.MeshStandardMaterial({
118
- color: lipColor,
119
- metalness: 0.05,
120
- roughness: 0.55
121
- })
122
-
123
- // ── 4 solid walls ────────────────────────────────────────────────
124
188
  const wallGeos: THREE.BufferGeometry[] = []
125
189
  const wallH = depth - lipH - floorH
126
190
  const wallY = baseY + floorH + wallH / 2
127
-
128
- // Long walls
129
191
  for (const zSign of [-1, 1]) {
130
192
  const wall = new THREE.BoxGeometry(width, wallH, wallThickness)
131
193
  wall.translate(0, wallY, zSign * (height / 2 - wallThickness / 2))
132
194
  wallGeos.push(wall)
133
195
  }
134
- // Short walls
135
196
  for (const xSign of [-1, 1]) {
136
197
  const wall = new THREE.BoxGeometry(wallThickness, wallH, height - 2 * wallThickness)
137
198
  wall.translate(xSign * (width / 2 - wallThickness / 2), wallY, 0)
138
199
  wallGeos.push(wall)
139
200
  }
140
- const wallMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(wallGeos), totMaterial)
141
- wallMesh.castShadow = true
142
- wallMesh.receiveShadow = true
143
- this.object3d.add(wallMesh)
201
+ const walls = BufferGeometryUtils.mergeGeometries(wallGeos)
144
202
 
145
- // ── Top lip (stackable rim — slightly wider than walls) ──────────
146
203
  const lipGeos: THREE.BufferGeometry[] = []
147
204
  const lipY = baseY + depth - lipH / 2
148
- // Long sides
149
205
  for (const zSign of [-1, 1]) {
150
- const lip = new THREE.BoxGeometry(width * 1.02, lipH, wallThickness * 1.5)
151
- lip.translate(0, lipY, zSign * (height / 2 - wallThickness * 0.75))
152
- lipGeos.push(lip)
206
+ const lipG = new THREE.BoxGeometry(width * 1.02, lipH, wallThickness * 1.5)
207
+ lipG.translate(0, lipY, zSign * (height / 2 - wallThickness * 0.75))
208
+ lipGeos.push(lipG)
153
209
  }
154
- // Short sides
155
210
  for (const xSign of [-1, 1]) {
156
- const lip = new THREE.BoxGeometry(wallThickness * 1.5, lipH, height * 1.02 - 2 * wallThickness * 1.5)
157
- lip.translate(xSign * (width / 2 - wallThickness * 0.75), lipY, 0)
158
- lipGeos.push(lip)
211
+ const lipG = new THREE.BoxGeometry(wallThickness * 1.5, lipH, height * 1.02 - 2 * wallThickness * 1.5)
212
+ lipG.translate(xSign * (width / 2 - wallThickness * 0.75), lipY, 0)
213
+ lipGeos.push(lipG)
159
214
  }
160
- const lipMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(lipGeos), lipMaterial)
161
- lipMesh.castShadow = true
162
- this.object3d.add(lipMesh)
215
+ const lip = BufferGeometryUtils.mergeGeometries(lipGeos)
163
216
 
164
- // ── Floor (solid bottom) ─────────────────────────────────────────
165
- const floorGeo = new THREE.BoxGeometry(width, floorH, height)
166
- const floorMesh = new THREE.Mesh(floorGeo, totMaterial)
167
- floorMesh.position.set(0, baseY + floorH / 2, 0)
168
- floorMesh.receiveShadow = true
169
- this.object3d.add(floorMesh)
217
+ const floor = new THREE.BoxGeometry(width, floorH, height)
218
+ const floorY = baseY + floorH / 2
219
+
220
+ cached = { walls, lip, floor, floorY }
221
+ plasticGeoCache.set(key, cached)
222
+ return cached
170
223
  }
171
224
 
172
225
  updateDimension() {}
package/src/pallet-3d.ts CHANGED
@@ -21,6 +21,57 @@ import * as THREE from 'three'
21
21
  import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
22
22
  import { RealObjectGroup } from '@hatiolab/things-scene'
23
23
 
24
+ // ── Material cache by bodyColor (shared across all Pallet3D instances) ──
25
+ // 동일 bodyColor 의 pallet 수십~수백 개 → GPU material 인스턴스 *1개로 통합*.
26
+ const woodBodyMaterials = new Map<string, THREE.MeshStandardMaterial>()
27
+ const woodStringerMaterials = new Map<string, THREE.MeshStandardMaterial>()
28
+ const plasticDeckMaterials = new Map<string, THREE.MeshStandardMaterial>()
29
+ const plasticFootMaterials = new Map<string, THREE.MeshStandardMaterial>()
30
+
31
+ function getWoodBodyMaterial(bodyColor: string): THREE.MeshStandardMaterial {
32
+ let m = woodBodyMaterials.get(bodyColor)
33
+ if (!m) {
34
+ m = new THREE.MeshStandardMaterial({ color: bodyColor, metalness: 0.0, roughness: 0.85 })
35
+ woodBodyMaterials.set(bodyColor, m)
36
+ }
37
+ return m
38
+ }
39
+
40
+ function getWoodStringerMaterial(bodyColor: string): THREE.MeshStandardMaterial {
41
+ let m = woodStringerMaterials.get(bodyColor)
42
+ if (!m) {
43
+ const tint = new THREE.Color(bodyColor).multiplyScalar(0.85)
44
+ m = new THREE.MeshStandardMaterial({ color: tint, metalness: 0.0, roughness: 0.9 })
45
+ woodStringerMaterials.set(bodyColor, m)
46
+ }
47
+ return m
48
+ }
49
+
50
+ function getPlasticDeckMaterial(bodyColor: string): THREE.MeshStandardMaterial {
51
+ let m = plasticDeckMaterials.get(bodyColor)
52
+ if (!m) {
53
+ m = new THREE.MeshStandardMaterial({ color: bodyColor, metalness: 0.1, roughness: 0.55 })
54
+ plasticDeckMaterials.set(bodyColor, m)
55
+ }
56
+ return m
57
+ }
58
+
59
+ function getPlasticFootMaterial(bodyColor: string): THREE.MeshStandardMaterial {
60
+ let m = plasticFootMaterials.get(bodyColor)
61
+ if (!m) {
62
+ const tint = new THREE.Color(bodyColor).multiplyScalar(0.85)
63
+ m = new THREE.MeshStandardMaterial({ color: tint, metalness: 0.1, roughness: 0.65 })
64
+ plasticFootMaterials.set(bodyColor, m)
65
+ }
66
+ return m
67
+ }
68
+
69
+ // ── Geometry cache by size (shared across all Pallet3D instances) ──
70
+ // 동일 width × height × depth 의 pallet → merged geometry *1세트*만 GPU 에 업로드.
71
+ // Translation / merge 비용도 동일 size pallet 끼리 *1회*만 발생.
72
+ const woodGeoCache = new Map<string, { top: THREE.BufferGeometry; stringer: THREE.BufferGeometry; bottom: THREE.BufferGeometry }>()
73
+ const plasticGeoCache = new Map<string, { deck: THREE.BufferGeometry; feet: THREE.BufferGeometry; brace: THREE.BufferGeometry }>()
74
+
24
75
  export class Pallet3D extends RealObjectGroup {
25
76
  build() {
26
77
  super.build()
@@ -38,28 +89,36 @@ export class Pallet3D extends RealObjectGroup {
38
89
 
39
90
  /** Wood EUR-style: 7 top slats + 3 stringers + 5 bottom slats. */
40
91
  private buildWood(width: number, height: number, depth: number, bodyColor: string) {
92
+ const { top, stringer, bottom } = this.getWoodGeometries(width, height, depth)
93
+
94
+ const woodMaterial = getWoodBodyMaterial(bodyColor)
95
+ const stringerMaterial = getWoodStringerMaterial(bodyColor)
96
+
97
+ const topSlatMesh = new THREE.Mesh(top, woodMaterial)
98
+ topSlatMesh.castShadow = true
99
+ topSlatMesh.receiveShadow = true
100
+ this.object3d.add(topSlatMesh)
101
+
102
+ const stringerMesh = new THREE.Mesh(stringer, stringerMaterial)
103
+ stringerMesh.castShadow = true
104
+ this.object3d.add(stringerMesh)
105
+
106
+ const botSlatMesh = new THREE.Mesh(bottom, woodMaterial)
107
+ botSlatMesh.receiveShadow = true
108
+ this.object3d.add(botSlatMesh)
109
+ }
110
+
111
+ private getWoodGeometries(width: number, height: number, depth: number) {
112
+ const key = `${width}|${height}|${depth}`
113
+ let cached = woodGeoCache.get(key)
114
+ if (cached) return cached
115
+
41
116
  const baseY = -depth / 2
42
117
  const slatThickness = depth * 0.15
43
118
  const stringerThickness = depth * 0.45
44
119
  const bottomSlatThickness = depth * 0.13
45
120
 
46
- const woodMaterial = new THREE.MeshStandardMaterial({
47
- color: bodyColor,
48
- metalness: 0.0,
49
- roughness: 0.85
50
- })
51
- const stringerColor = new THREE.Color(bodyColor).multiplyScalar(0.85)
52
- const stringerMaterial = new THREE.MeshStandardMaterial({
53
- color: stringerColor,
54
- metalness: 0.0,
55
- roughness: 0.9
56
- })
57
-
58
- // ── Top + bottom slats — same count, same z-positions, paired vertically ─
59
- // EUR-pallet style: 5 boards on top, 5 below (under the same z ranges so
60
- // they read as a single skeleton rather than two unrelated grids).
61
121
  const slatCount = 5
62
- const slatW = width
63
122
  const slatD = (height * 0.92) / (slatCount + (slatCount - 1) * 0.4)
64
123
  const gapD = slatD * 0.4
65
124
  const totalSpan = slatCount * slatD + (slatCount - 1) * gapD
@@ -72,16 +131,12 @@ export class Pallet3D extends RealObjectGroup {
72
131
 
73
132
  const topSlatGeos: THREE.BufferGeometry[] = []
74
133
  for (const z of slatPositions) {
75
- const slat = new THREE.BoxGeometry(slatW, slatThickness, slatD)
134
+ const slat = new THREE.BoxGeometry(width, slatThickness, slatD)
76
135
  slat.translate(0, baseY + depth - slatThickness / 2, z)
77
136
  topSlatGeos.push(slat)
78
137
  }
79
- const topSlatMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(topSlatGeos), woodMaterial)
80
- topSlatMesh.castShadow = true
81
- topSlatMesh.receiveShadow = true
82
- this.object3d.add(topSlatMesh)
138
+ const top = BufferGeometryUtils.mergeGeometries(topSlatGeos)
83
139
 
84
- // ── Stringers (3 perpendicular blocks between top and bottom decks) ─
85
140
  const stringerCount = 3
86
141
  const stringerW = width * 0.07
87
142
  const stringerY = baseY + bottomSlatThickness + stringerThickness / 2
@@ -93,50 +148,59 @@ export class Pallet3D extends RealObjectGroup {
93
148
  stringer.translate(x, stringerY, 0)
94
149
  stringerGeos.push(stringer)
95
150
  }
96
- const stringerMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(stringerGeos), stringerMaterial)
97
- stringerMesh.castShadow = true
98
- this.object3d.add(stringerMesh)
151
+ const stringer = BufferGeometryUtils.mergeGeometries(stringerGeos)
99
152
 
100
- // ── Bottom slats — same z-positions as top so the deck reads as paired ─
101
153
  const botSlatGeos: THREE.BufferGeometry[] = []
102
154
  for (const z of slatPositions) {
103
155
  const slat = new THREE.BoxGeometry(width, bottomSlatThickness, slatD)
104
156
  slat.translate(0, baseY + bottomSlatThickness / 2, z)
105
157
  botSlatGeos.push(slat)
106
158
  }
107
- const botSlatMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(botSlatGeos), woodMaterial)
108
- botSlatMesh.receiveShadow = true
109
- this.object3d.add(botSlatMesh)
159
+ const bottom = BufferGeometryUtils.mergeGeometries(botSlatGeos)
160
+
161
+ cached = { top, stringer, bottom }
162
+ woodGeoCache.set(key, cached)
163
+ return cached
110
164
  }
111
165
 
112
166
  /** Plastic molded: solid top deck + ribbed underside / feet. */
113
167
  private buildPlastic(width: number, height: number, depth: number, bodyColor: string) {
168
+ const { deck, feet, brace } = this.getPlasticGeometries(width, height, depth)
169
+
170
+ const deckMaterial = getPlasticDeckMaterial(bodyColor)
171
+ const footMaterial = getPlasticFootMaterial(bodyColor)
172
+
114
173
  const baseY = -depth / 2
115
174
  const deckThickness = depth * 0.30
116
- const footH = depth * 0.55
117
- const footW = width * 0.12
118
175
 
119
- const deckMaterial = new THREE.MeshStandardMaterial({
120
- color: bodyColor,
121
- metalness: 0.1,
122
- roughness: 0.55
123
- })
124
- const footColor = new THREE.Color(bodyColor).multiplyScalar(0.85)
125
- const footMaterial = new THREE.MeshStandardMaterial({
126
- color: footColor,
127
- metalness: 0.1,
128
- roughness: 0.65
129
- })
130
-
131
- // ── Solid top deck ───────────────────────────────────────────────
132
- const deckGeo = new THREE.BoxGeometry(width * 0.98, deckThickness, height * 0.98)
133
- const deckMesh = new THREE.Mesh(deckGeo, deckMaterial)
176
+ const deckMesh = new THREE.Mesh(deck, deckMaterial)
134
177
  deckMesh.position.set(0, baseY + depth - deckThickness / 2, 0)
135
178
  deckMesh.castShadow = true
136
179
  deckMesh.receiveShadow = true
137
180
  this.object3d.add(deckMesh)
138
181
 
139
- // ── 9 feet (3×3 grid — typical plastic pallet underside) ─────────
182
+ const footMesh = new THREE.Mesh(feet, footMaterial)
183
+ footMesh.castShadow = true
184
+ this.object3d.add(footMesh)
185
+
186
+ const braceMesh = new THREE.Mesh(brace, footMaterial)
187
+ this.object3d.add(braceMesh)
188
+ }
189
+
190
+ private getPlasticGeometries(width: number, height: number, depth: number) {
191
+ const key = `${width}|${height}|${depth}`
192
+ let cached = plasticGeoCache.get(key)
193
+ if (cached) return cached
194
+
195
+ const baseY = -depth / 2
196
+ const deckThickness = depth * 0.30
197
+ const footH = depth * 0.55
198
+ const footW = width * 0.12
199
+
200
+ // Deck geo — local space; mesh position applied at instantiation.
201
+ const deck = new THREE.BoxGeometry(width * 0.98, deckThickness, height * 0.98)
202
+
203
+ // 9 feet (3×3 grid) → merged geometry with translations baked in.
140
204
  const footGeos: THREE.BufferGeometry[] = []
141
205
  for (let i = -1; i <= 1; i++) {
142
206
  for (let j = -1; j <= 1; j++) {
@@ -147,18 +211,21 @@ export class Pallet3D extends RealObjectGroup {
147
211
  footGeos.push(foot)
148
212
  }
149
213
  }
150
- const footMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(footGeos), footMaterial)
151
- footMesh.castShadow = true
152
- this.object3d.add(footMesh)
214
+ const feet = BufferGeometryUtils.mergeGeometries(footGeos)
153
215
 
154
- // ── Cross-bracing along underside (suggests molded reinforcement)
216
+ // 3 braces merged into single mesh (이전에 mesh 3개 분리 = drawcall 3개).
155
217
  const braceH = depth * 0.10
156
- const braceGeo = new THREE.BoxGeometry(width * 0.95, braceH, height * 0.04)
218
+ const braceGeos: THREE.BufferGeometry[] = []
157
219
  for (const zSign of [-1, 0, 1]) {
158
- const brace = new THREE.Mesh(braceGeo.clone(), footMaterial)
159
- brace.position.set(0, baseY + footH - braceH / 2, zSign * height * 0.4)
160
- this.object3d.add(brace)
220
+ const b = new THREE.BoxGeometry(width * 0.95, braceH, height * 0.04)
221
+ b.translate(0, baseY + footH - braceH / 2, zSign * height * 0.4)
222
+ braceGeos.push(b)
161
223
  }
224
+ const brace = BufferGeometryUtils.mergeGeometries(braceGeos)
225
+
226
+ cached = { deck, feet, brace }
227
+ plasticGeoCache.set(key, cached)
228
+ return cached
162
229
  }
163
230
 
164
231
  updateDimension() {}