@fmsim/machine 2.0.1 → 3.0.0-beta.1
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/dist/agv.js +2 -3
- package/dist/agv.js.map +1 -1
- package/dist/carrier.js +53 -43
- package/dist/carrier.js.map +1 -1
- package/dist/crane.js +37 -2
- package/dist/crane.js.map +1 -1
- package/dist/editors/index.js +2 -0
- package/dist/editors/index.js.map +1 -1
- package/dist/editors/load-layout-host.js +96 -0
- package/dist/editors/load-layout-host.js.map +1 -0
- package/dist/editors/ox-load-layout-panel.js +124 -0
- package/dist/editors/ox-load-layout-panel.js.map +1 -0
- package/dist/factories/crane-3d.js +77 -18
- package/dist/factories/crane-3d.js.map +1 -1
- package/dist/factories/machine-3d.js +29 -52
- package/dist/factories/machine-3d.js.map +1 -1
- package/dist/features/capacity-gauge-mixin.js +2 -3
- package/dist/features/capacity-gauge-mixin.js.map +1 -1
- package/dist/features/load-layout-holder.js +77 -0
- package/dist/features/load-layout-holder.js.map +1 -0
- package/dist/features/mcs-status-default.js +30 -7
- package/dist/features/mcs-status-default.js.map +1 -1
- package/dist/features/mcs-status-mixin.js +3 -3
- package/dist/features/mcs-status-mixin.js.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/mcs-carrier-holder.js +287 -82
- package/dist/mcs-carrier-holder.js.map +1 -1
- package/dist/oht.js +4 -0
- package/dist/oht.js.map +1 -1
- package/dist/port.js +6 -1
- package/dist/port.js.map +1 -1
- package/dist/scene/root-container-override.js +5 -11
- package/dist/scene/root-container-override.js.map +1 -1
- package/dist/shelf.js +4 -0
- package/dist/shelf.js.map +1 -1
- package/dist/shuttle.js +4 -0
- package/dist/shuttle.js.map +1 -1
- package/dist/stocker-carrier-bar.js +4 -4
- package/dist/stocker-carrier-bar.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/carrier-type-code.js +10 -0
- package/dist/utils/carrier-type-code.js.map +1 -0
- package/dist/utils/crane-forks.js +5 -0
- package/dist/utils/crane-forks.js.map +1 -0
- package/dist/utils/layout-issue-text.js +16 -0
- package/dist/utils/layout-issue-text.js.map +1 -0
- package/dist/utils/layout-issue.js +16 -0
- package/dist/utils/layout-issue.js.map +1 -0
- package/dist/utils/load-address.js +217 -0
- package/dist/utils/load-address.js.map +1 -0
- package/dist/utils/stocker-gauge.js +3 -2
- package/dist/utils/stocker-gauge.js.map +1 -1
- package/jest.config.js +9 -1
- package/package.json +9 -6
- package/src/agv.ts +2 -3
- package/src/carrier.ts +50 -41
- package/src/crane.ts +39 -2
- package/src/editors/index.ts +2 -0
- package/src/editors/load-layout-host.ts +121 -0
- package/src/editors/ox-load-layout-panel.ts +129 -0
- package/src/factories/crane-3d.ts +74 -20
- package/src/factories/machine-3d.ts +26 -49
- package/src/features/capacity-gauge-mixin.ts +2 -3
- package/src/features/load-layout-holder.ts +83 -0
- package/src/features/mcs-status-default.ts +29 -10
- package/src/features/mcs-status-mixin.ts +3 -7
- package/src/index.ts +7 -0
- package/src/mcs-carrier-holder.ts +319 -78
- package/src/oht.ts +5 -0
- package/src/port.ts +7 -1
- package/src/scene/root-container-override.ts +4 -14
- package/src/shelf.ts +5 -0
- package/src/shuttle.ts +5 -0
- package/src/stocker-carrier-bar.ts +4 -4
- package/src/utils/carrier-type-code.ts +9 -0
- package/src/utils/crane-forks.ts +4 -0
- package/src/utils/layout-issue-text.ts +18 -0
- package/src/utils/layout-issue.ts +41 -0
- package/src/utils/load-address.ts +270 -0
- package/src/utils/stocker-gauge.ts +3 -3
- package/translations/en.json +42 -1
- package/translations/ja.json +42 -1
- package/translations/ko.json +59 -18
- package/translations/ms.json +42 -1
- package/translations/zh.json +42 -1
- package/dist/carrier-manager.js +0 -191
- package/dist/carrier-manager.js.map +0 -1
- package/dist/features/animation-default.js +0 -6
- package/dist/features/animation-default.js.map +0 -1
- package/dist/utils/get-value-on-ranges.js +0 -21
- package/dist/utils/get-value-on-ranges.js.map +0 -1
- package/src/carrier-manager.ts +0 -231
- package/src/features/animation-default.ts +0 -13
- package/src/utils/get-value-on-ranges.ts +0 -23
package/src/carrier.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import JSON5 from 'json5'
|
|
2
2
|
import { getPopupData } from '@fmsim/api'
|
|
3
3
|
|
|
4
|
-
import { BOUNDS,
|
|
5
|
-
import { ANIMATION_DEFAULT,
|
|
4
|
+
import { BOUNDS, ComponentNature, Item, TRANSFER_SLOT_KEY, figureLegendSourcesChanged, registerFigureCapability, sceneComponent } from '@hatiolab/things-scene'
|
|
5
|
+
import { ANIMATION_DEFAULT, CARRIER_FIGURE, type AnimationConfig, type AnimationPreset } from '@fmsim/figure-capabilities'
|
|
6
6
|
import { ParentObjectMixin } from './features/parent-object-mixin.js'
|
|
7
7
|
import { Legend, resolveCarrierLegend } from './features/mcs-status-default.js'
|
|
8
8
|
import { MCSStatusMixin } from './features/mcs-status-mixin.js'
|
|
9
|
+
import { normalizeTypeCode } from './utils/carrier-type-code'
|
|
9
10
|
|
|
10
11
|
const NATURE: ComponentNature = {
|
|
11
12
|
mutable: false,
|
|
@@ -28,12 +29,26 @@ const NATURE: ComponentNature = {
|
|
|
28
29
|
*/
|
|
29
30
|
const STATUS_TEXT_COUNTER_ROTATE = true
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* 캐리어는 아이템이다 (공통 아이템 계약, ADR-0167 6). 컴포넌트 타입은 Carrier 그대로이고, 아이템 타입은 CARRIERTYPE 이다.
|
|
34
|
+
* 3D 는 그 타입이 이름한 피규어(carrier-types)가 그리고, 그리는 피규어가 없으면 fmsim 의 캐리어 상자다. 2D 는 그대로.
|
|
35
|
+
*/
|
|
31
36
|
@sceneComponent('Carrier')
|
|
32
|
-
export default class Carrier extends MCSStatusMixin(ParentObjectMixin(
|
|
37
|
+
export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Item)) {
|
|
33
38
|
static get nature() {
|
|
34
39
|
return NATURE
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
/** 아이템 타입 코드: CARRIERTYPE 을 라이브 데이터 비교 규칙대로 (fmsim 문서 01, 6.8 — trim·대문자) */
|
|
43
|
+
get itemTypeCode(): string {
|
|
44
|
+
return normalizeTypeCode(this.data?.CARRIERTYPE ?? this.state.CARRIERTYPE)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 3D 가 아이템의 것(피규어)인지: 타입의 피규어가 있을 때만. 없으면 'Carrier' 로 등록된 fmsim 상자가 지어진다 */
|
|
48
|
+
get drawnAsItem(): boolean {
|
|
49
|
+
return !!this.look
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
get decorators() {
|
|
38
53
|
return ['decotag']
|
|
39
54
|
}
|
|
@@ -59,34 +74,26 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
|
|
|
59
74
|
/** 마지막으로 적용한 (애니메이션 테마 | CARRIERSTATUS) 조합. 같은 조합 재적용을 막는다. */
|
|
60
75
|
private _appliedAnimationKey?: string
|
|
61
76
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// 걸려 본래 두께가 덮어써진다 (= 사용자 호소 "두께가 줄어들었다" 의 원인).
|
|
70
|
-
if (parent) {
|
|
71
|
-
const { width, height } = parent.state || {}
|
|
72
|
-
if (width && height) {
|
|
73
|
-
this.set({ width, height })
|
|
74
|
-
}
|
|
75
|
-
}
|
|
77
|
+
/**
|
|
78
|
+
* 홀더가 받았다 (공통 아이템 계약 3.5 의 handover 끝). 2D 는 홀더 평면을 덮으므로 width/height 를 새 홀더에 맞춘다.
|
|
79
|
+
* depth 는 건드리지 않는다 — 3D 크기는 홀더가 보이는 치수(itemSeatOf().dims)로 정한다.
|
|
80
|
+
*/
|
|
81
|
+
held(holder: any) {
|
|
82
|
+
const { width, height } = holder?.state || {}
|
|
83
|
+
if (width && height) this.set({ width, height })
|
|
76
84
|
|
|
77
85
|
this.syncStateFromData()
|
|
78
86
|
|
|
79
87
|
/* 부모가 정해진 지금 다시 해석한다 — 생성 시(data 선할당) 판정에는 parent 가 없어
|
|
80
|
-
* 홀더의 carrierAnimationName 이 반영되지 않았다.
|
|
88
|
+
* 홀더의 carrierAnimationName 이 반영되지 않았다. 홀더가 바뀐 경우도 여기서
|
|
81
89
|
* 새 홀더 테마로 갱신된다. (같은 조합이면 내부 가드로 no-op) */
|
|
82
90
|
this._applyStatusAnimation()
|
|
83
91
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
;(this.root as any).model_layer?._threeCapability?.addObject(this)
|
|
87
|
-
}
|
|
92
|
+
/* 범례는 홀더가 물려준다: 새 홀더의 범례로 이 Carrier 의 피규어가 범례를 다시 고른다 (ADR-0166 결정 12) */
|
|
93
|
+
figureLegendSourcesChanged(this)
|
|
88
94
|
|
|
89
|
-
//
|
|
95
|
+
// 새 홀더가 보이는 치수로 3D 를 다시 만든다 (자리는 홀더가 앉힌다)
|
|
96
|
+
;(this._realObject as any)?.update?.()
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
/**
|
|
@@ -103,6 +110,7 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
|
|
|
103
110
|
|
|
104
111
|
onchangeData(after: Record<string, any>, before: Record<string, any>): void {
|
|
105
112
|
const { CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS } = this.data || {}
|
|
113
|
+
const typeChanged = (after.data?.CARRIERTYPE ?? '') !== (before.data?.CARRIERTYPE ?? '')
|
|
106
114
|
|
|
107
115
|
this.setState('id', CARRIERNAME)
|
|
108
116
|
this.setState('EMPTYTYPE', EMPTYTYPE)
|
|
@@ -113,6 +121,13 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
|
|
|
113
121
|
* (테마|상태) 키 비교로 하므로 여기서 status 변경 여부를 따지지 않는다 —
|
|
114
122
|
* 상태는 그대로여도 홀더가 바뀌어(=테마가 바뀌어) 재적용이 필요할 수 있다. */
|
|
115
123
|
this._applyStatusAnimation()
|
|
124
|
+
|
|
125
|
+
/* 같은 Carrier 의 타입이 바뀌었다 (계약 Q10): 그 타입의 피규어가 모양이 되고, 보이는 치수로 3D 를 다시 만들고, 그 자리를 다시 앉힌다 */
|
|
126
|
+
if (typeChanged) this.refreshLook()
|
|
127
|
+
if (typeChanged && this.parent) {
|
|
128
|
+
;(this._realObject as any)?.update?.()
|
|
129
|
+
;(this.parent as any)._reseatSlot?.((this as any)[TRANSFER_SLOT_KEY])
|
|
130
|
+
}
|
|
116
131
|
}
|
|
117
132
|
|
|
118
133
|
/**
|
|
@@ -123,8 +138,8 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
|
|
|
123
138
|
*
|
|
124
139
|
* 호출 시점이 둘인 이유:
|
|
125
140
|
* 1) onchangeData — 상태(또는 테마) 변화 반영
|
|
126
|
-
* 2)
|
|
127
|
-
*
|
|
141
|
+
* 2) held() — **홀더가 받은 직후**. 홀더는 data 를 넣어 Carrier 를 만든 뒤
|
|
142
|
+
* 받으므로, 생성 시 첫 판정에는 parent 가 없어 홀더의
|
|
128
143
|
* carrierAnimationName 이 무시된다(루트 폴백만 보임). 부모가 붙은 뒤 한 번 더
|
|
129
144
|
* 해석해야 홀더 테마가 반영된다.
|
|
130
145
|
*/
|
|
@@ -184,7 +199,7 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
|
|
|
184
199
|
* 적용할 애니메이션 테마 이름 — 소유 홀더(부모) 설정이 우선, 없으면 보드(rootModel) 폴백.
|
|
185
200
|
*
|
|
186
201
|
* 부모가 아직 없는 시점(생성 직후 data 선할당)에는 홀더 설정을 알 수 없어 루트 폴백만
|
|
187
|
-
* 보인다. 그래서
|
|
202
|
+
* 보인다. 그래서 held() 에서 한 번 더 해석한다 — _applyStatusAnimation 참조.
|
|
188
203
|
*/
|
|
189
204
|
get resolvedCarrierAnimationName(): string {
|
|
190
205
|
return (
|
|
@@ -296,16 +311,12 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
|
|
|
296
311
|
return
|
|
297
312
|
}
|
|
298
313
|
|
|
299
|
-
// 2D 에서는 stack 의 *맨 위 (
|
|
300
|
-
//
|
|
301
|
-
// 2D 시각에는 노출하지 않는다 — 정책: "2D 는 현행처럼" (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const siblings = parent.components
|
|
306
|
-
if (siblings[siblings.length - 1] !== this) {
|
|
307
|
-
return
|
|
308
|
-
}
|
|
314
|
+
// 2D 에서는 홀더 데이터 stack 의 *맨 위 (MULTICARRIERS 다음 CARRIERNAME 순서의 마지막)* 1개만 그린다.
|
|
315
|
+
// 다른 carrier 들도 인스턴스로는 살아있어 조회·이동이 가능하지만
|
|
316
|
+
// 2D 시각에는 노출하지 않는다 — 정책: "2D 는 현행처럼" (1개만). 판단 기준은 위치 — carrier 간 그룹/종속 정보는 두지 않는다.
|
|
317
|
+
const top = (this.parent as any)?.topCarrier
|
|
318
|
+
if (top && top !== this) {
|
|
319
|
+
return
|
|
309
320
|
}
|
|
310
321
|
|
|
311
322
|
const { width, height } = this.bounds
|
|
@@ -362,11 +373,6 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
|
|
|
362
373
|
}
|
|
363
374
|
}
|
|
364
375
|
|
|
365
|
-
dispose(): void {
|
|
366
|
-
;(this.root as any)?.carrierManager?.onCarrierDisposed(this.state.id)
|
|
367
|
-
|
|
368
|
-
super.dispose()
|
|
369
|
-
}
|
|
370
376
|
|
|
371
377
|
async detailInfo() {
|
|
372
378
|
const { type, id } = this.state
|
|
@@ -378,3 +384,6 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
|
|
|
378
384
|
return await getPopupData(type, id)
|
|
379
385
|
}
|
|
380
386
|
}
|
|
387
|
+
|
|
388
|
+
/* fmsim.Carrier (ADR-0167 12): what it gives a figure is its tables, handed by its descriptor -- no mixin of its own */
|
|
389
|
+
registerFigureCapability(CARRIER_FIGURE, base => class extends base {})
|
package/src/crane.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Component, ComponentNature, sceneComponent, frameClock } from '@hatiolab/things-scene'
|
|
2
2
|
import MCSVehicle from './mcs-vehicle'
|
|
3
3
|
import { LEGEND_CRANE } from './features/mcs-status-default'
|
|
4
|
+
import { LoadLayoutHolder } from './features/load-layout-holder'
|
|
5
|
+
import { HOLDER_ANCHOR, forkAnchorId } from '@hatiolab/figure-model'
|
|
6
|
+
import { craneForkCount } from './utils/crane-forks'
|
|
4
7
|
import {
|
|
5
8
|
axisRatio,
|
|
6
9
|
mapCraneLocalPosition,
|
|
@@ -12,9 +15,18 @@ const NATURE: ComponentNature = {
|
|
|
12
15
|
mutable: false,
|
|
13
16
|
resizable: true,
|
|
14
17
|
rotatable: true,
|
|
15
|
-
properties: [
|
|
18
|
+
properties: [
|
|
19
|
+
...MCSVehicle.properties,
|
|
20
|
+
{
|
|
21
|
+
type: 'select',
|
|
22
|
+
label: 'fork-count',
|
|
23
|
+
name: 'forkCount',
|
|
24
|
+
property: { options: ['1', '2'] }
|
|
25
|
+
}
|
|
26
|
+
]
|
|
16
27
|
}
|
|
17
28
|
|
|
29
|
+
|
|
18
30
|
const GAP = 2
|
|
19
31
|
|
|
20
32
|
/** 좌표 보간 기본 duration (ms). data.DURATION_MS 가 없으면 이 값 사용. */
|
|
@@ -41,11 +53,36 @@ interface CranePositionAnimator {
|
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
@sceneComponent('Crane')
|
|
44
|
-
export default class Crane extends MCSVehicle {
|
|
56
|
+
export default class Crane extends LoadLayoutHolder(MCSVehicle) {
|
|
45
57
|
static get nature() {
|
|
46
58
|
return NATURE
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
/**
|
|
62
|
+
* 크레인이 주는 Anchor (설계 02번 3.7.2절): 홀더 원점과 포크마다 하나(fork_1, fork_2). 포크가 하나뿐인 장비에
|
|
63
|
+
* fork_2 에 선 Tier 가 있으면 레이아웃 전체가 잘못된 값이다: Carrier 를 자리 없이 한 무더기로 쌓고 패널에 오류를 보인다
|
|
64
|
+
* (ADR-0051 4.1절 결정 8).
|
|
65
|
+
*/
|
|
66
|
+
slotAnchors(): string[] {
|
|
67
|
+
const forks = craneForkCount((this.state as any).forkCount)
|
|
68
|
+
return [HOLDER_ANCHOR, ...Array.from({ length: forks }, (_, i) => forkAnchorId(i + 1))]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 포크 Anchor 는 크레인 3D 의 그 포크 판 윗면 노드다. 포크가 오르내리고 뻗으면 그 위의 Carrier 가 함께 간다 */
|
|
72
|
+
anchorObject3D(anchorId: string): any {
|
|
73
|
+
return (this as any)._realObject?.forkAnchor?.(anchorId) ?? super.anchorObject3D(anchorId)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 포크 수가 바뀌면 주는 Anchor 가 바뀌므로, 데이터가 다시 오지 않아도 지금 데이터로 자리를 다시 배정한다 */
|
|
77
|
+
onchange(after: Record<string, any>, before: Record<string, any>): void {
|
|
78
|
+
super.onchange(after, before)
|
|
79
|
+
if ('forkCount' in after && craneForkCount(after.forkCount) !== craneForkCount(before?.forkCount)) {
|
|
80
|
+
;(this as any)._carrierRecordsTaken = undefined
|
|
81
|
+
;(this as any).syncCarriers(this.data || {})
|
|
82
|
+
;(this as any)._carriersSeatedAgain()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
49
86
|
get auxColor() {
|
|
50
87
|
return 'black'
|
|
51
88
|
}
|
package/src/editors/index.ts
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* fmsim 이 공통 편집기(@operato/slot-layout-editor)에 주는 것 (설계 02번 3.7.1절)
|
|
3
|
+
* Tier·Slot 편집, 검증, 미리보기, 적용 전 주소 영향은 공통 편집기가 한다. 기종 프리셋·실효 레이아웃·검증은 figure-model 의
|
|
4
|
+
* 것이다(ADR-0051 4.1절 결정 8). 이 패널은 도면 노드의 배치 덮어쓰기 화면이고, fmsim 은 자기 몫만 넘긴다:
|
|
5
|
+
* 프리셋 이름, 홀더가 주는 Anchor, 값의 출처·도면 단위 안내, 실시간 주소 읽기 설정과 그 검증, fmsim 고유 오류 문구.
|
|
6
|
+
* 화면 요소를 만들지 않는 순수 모듈이다 — 문구 함수(t)와 공통 문구 함수는 부르는 쪽이 준다.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SlotLayoutEditorOptions, SlotLayoutPreset } from '@operato/slot-layout-editor'
|
|
10
|
+
import {
|
|
11
|
+
figureSlotLayoutIssues,
|
|
12
|
+
slotLayoutPreset,
|
|
13
|
+
SLOT_LAYOUT_PRESET_KEYS,
|
|
14
|
+
SLOT_LAYOUT_PRESET_PARAMS,
|
|
15
|
+
type LayoutIssue as SlotLayoutIssue,
|
|
16
|
+
type SlotLayoutPresetKey,
|
|
17
|
+
type SlotMount
|
|
18
|
+
} from '@hatiolab/figure-model'
|
|
19
|
+
import { LoadAddressing, LoadLayout, validateAddressing } from '../utils/load-address'
|
|
20
|
+
import { LayoutIssue, LayoutIssueCode } from '../utils/layout-issue'
|
|
21
|
+
import { ISSUE_TEXT } from '../utils/layout-issue-text'
|
|
22
|
+
|
|
23
|
+
export type Translate = (key: string, fallback: string, options?: Record<string, unknown>) => string
|
|
24
|
+
|
|
25
|
+
/** 문제 하나: 코드·값의 위치·매개변수 */
|
|
26
|
+
export type IssueLike = { code: string; path: string; params?: Record<string, string | number> }
|
|
27
|
+
|
|
28
|
+
/** 공통 편집기의 문제 문구 (층·자리 검증 코드) */
|
|
29
|
+
export type CommonIssueText = (issue: IssueLike) => string
|
|
30
|
+
|
|
31
|
+
// ── 기종 프리셋 (3.7절, figure-model) ─────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const PRESET_LABELS: Record<SlotLayoutPresetKey, string> = {
|
|
34
|
+
single: 'Single slot',
|
|
35
|
+
agvTandem: 'AGV tandem (front/rear)',
|
|
36
|
+
agvParallel: 'AGV parallel (left/right)',
|
|
37
|
+
agv2x2: 'AGV 2×2',
|
|
38
|
+
bufferPort2x2: 'Buffer port 2×2',
|
|
39
|
+
multiBufferPort: 'Multi-buffer port (4)',
|
|
40
|
+
dualTierConveyor: 'Dual-tier conveyor',
|
|
41
|
+
craneTwinFork: 'Crane twin fork',
|
|
42
|
+
floorBlock: 'Floor block'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PARAM_LABELS: Record<string, string> = {
|
|
46
|
+
upperElevation: 'Upper tier elevation (mm)',
|
|
47
|
+
rows: 'Rows',
|
|
48
|
+
cols: 'Columns',
|
|
49
|
+
stackLimit: 'Stack limit'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 공통 편집기의 프리셋 목록. 홀더 원점·포크 Anchor 위에 펼치고, 단일 자리는 홀더의 적재 방식으로 만든다 */
|
|
53
|
+
export function fmsimPresets(mount: SlotMount, t: Translate): SlotLayoutPreset<LoadLayout>[] {
|
|
54
|
+
return SLOT_LAYOUT_PRESET_KEYS.map(key => ({
|
|
55
|
+
key,
|
|
56
|
+
label: t(`preset-${key}`, PRESET_LABELS[key]),
|
|
57
|
+
params: SLOT_LAYOUT_PRESET_PARAMS[key].map(p => ({ key: p.key, label: t(`param-${p.key}`, PARAM_LABELS[p.key]), integer: p.integer })),
|
|
58
|
+
build: (size, params) => slotLayoutPreset(key, size, params, { mount })
|
|
59
|
+
}))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── 실시간 주소 읽기 (3.6절) ────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export function setAddressing(layout: LoadLayout, addressing: LoadAddressing | undefined): LoadLayout {
|
|
65
|
+
const next = structuredClone(layout)
|
|
66
|
+
if (addressing) next.addressing = structuredClone(addressing)
|
|
67
|
+
else delete next.addressing
|
|
68
|
+
return next
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── 편집기 옵션 ─────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/** fmsim 고유 오류(주소 읽기 등)의 문구. fmsim 코드가 아니면 undefined — 공통 편집기 문구를 쓴다 */
|
|
74
|
+
export function fmsimIssueText(issue: IssueLike, t: Translate): string | undefined {
|
|
75
|
+
const fallback = ISSUE_TEXT[issue.code as LayoutIssueCode]
|
|
76
|
+
if (!fallback) return undefined
|
|
77
|
+
const text = t(`issue.${issue.code}`, fallback, { ...issue.params, interpolation: { escapeValue: false } })
|
|
78
|
+
return issue.path ? `${issue.path}: ${text}` : text
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 공통 편집기가 보는 것 밖의 검증: 실효 레이아웃이 덮어쓰기에 거는 figure 규칙(받는 타입은 생략하면 모든 아이템, 적으면
|
|
83
|
+
* 비지 않은 목록, ADR-0051 4.1절 결정 5·7)과 주소 읽기 설정. 공통 검증 결과는 편집기가 이미 보이므로 figure 고유 코드만 더한다.
|
|
84
|
+
*/
|
|
85
|
+
export function holderLayoutIssues(layout: LoadLayout, anchors: readonly string[]): (SlotLayoutIssue | LayoutIssue)[] {
|
|
86
|
+
return [...figureSlotLayoutIssues(layout, anchors).filter(i => i.code === 'ACCEPTS_EMPTY'), ...validateAddressing(layout.addressing)]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 선택한 홀더(LoadLayoutHolder)에서 공통 편집기 옵션을 만든다. value 는 도면 노드의 개별 설정 */
|
|
90
|
+
export function holderEditorOptions(holder: any, value: LoadLayout | undefined, t: Translate, commonText: CommonIssueText): SlotLayoutEditorOptions<LoadLayout> {
|
|
91
|
+
const sel = holder.effectiveLayout
|
|
92
|
+
const mm = holder.mmPerUnit
|
|
93
|
+
const state = holder.state || {}
|
|
94
|
+
const anchors: string[] = holder.slotAnchors?.() ?? []
|
|
95
|
+
const source: Record<string, string> = {
|
|
96
|
+
override: t('source-node', 'Set on this holder'),
|
|
97
|
+
figure: t('source-builtin', 'Built-in default'),
|
|
98
|
+
none: t('source-none', 'Not defined (one slot, stacked)')
|
|
99
|
+
}
|
|
100
|
+
const notes: SlotLayoutEditorOptions['notes'] = [{ text: `${t('source', 'Source')}: ${source[sel.source]}` }]
|
|
101
|
+
if (sel.status === 'invalid') {
|
|
102
|
+
notes.push({ level: 'error', text: t('invalid', 'The saved setting is invalid. Shown as one slot until fixed.') })
|
|
103
|
+
for (const e of sel.issues as SlotLayoutIssue[]) notes.push({ level: 'error', text: commonText(e) })
|
|
104
|
+
}
|
|
105
|
+
notes.push(
|
|
106
|
+
mm?.known
|
|
107
|
+
? { text: t('unit-ok', 'Drawing unit 1 = {mm} mm', { mm: mm.mmPerUnit }) }
|
|
108
|
+
: { level: 'error', text: t('unit-missing', 'Drawing unit is not set (mmPerUnit). Physical placement and fit checks are unavailable.') }
|
|
109
|
+
)
|
|
110
|
+
return {
|
|
111
|
+
// 개별 설정이 없으면 실효 레이아웃(기본값)에서 편집을 시작한다
|
|
112
|
+
effective: value === undefined && sel.status === 'valid' ? sel.layout : undefined,
|
|
113
|
+
canUseDefault: value !== undefined,
|
|
114
|
+
presets: fmsimPresets(holder.loadMount ?? 'ON', t),
|
|
115
|
+
presetSize: mm?.known ? { width: Math.abs(state.width || 0) * mm.mmPerUnit, depth: Math.abs(state.height || 0) * mm.mmPerUnit } : {},
|
|
116
|
+
notes,
|
|
117
|
+
anchors: anchors.map(id => ({ id })),
|
|
118
|
+
validate: (layout: LoadLayout) => holderLayoutIssues(layout, anchors),
|
|
119
|
+
issueText: issue => fmsimIssueText(issue, t)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license Copyright © HatioLab Inc. All rights reserved.
|
|
3
|
+
*
|
|
4
|
+
* "Carrier 적재 자리" 구역 (설계 02번 3.7.1절)
|
|
5
|
+
* 공통 편집기(@operato/slot-layout-editor 의 ox-slot-layout-editor)를 띄우고 fmsim 의 몫만 넘긴다:
|
|
6
|
+
* 프리셋 이름, 홀더가 주는 Anchor, 값의 출처·도면 단위, 실시간 주소 읽기 설정, fmsim 고유 오류 문구 (load-layout-host).
|
|
7
|
+
* 이 화면은 도면 노드의 배치 덮어쓰기다. 프리셋·실효 레이아웃·검증은 figure-model 의 것이다 (ADR-0051 4.1절 결정 8).
|
|
8
|
+
* [적용]이면 고친 값을, [기본값 사용]이면 개별 설정을 지운 값(undefined)을 도면 노드에 넘긴다. 실행 중 점유는 저장하지 않는다.
|
|
9
|
+
*
|
|
10
|
+
* 속성 패널(board specifics)에서 custom-editor · value-key="slotLayout" 로 쓴다. value 를 바꾸고 change 를 보낸다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { css, html, nothing, LitElement } from 'lit'
|
|
14
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
15
|
+
import { i18next, localize } from '@operato/i18n'
|
|
16
|
+
// 패키지 루트가 ox-slot-layout-editor 를 정의한다 (fmsim 의 moduleResolution 은 하위 경로 exports 를 풀지 않는다)
|
|
17
|
+
import { issueText as commonIssueText } from '@operato/slot-layout-editor'
|
|
18
|
+
|
|
19
|
+
import { LoadAddressing, LoadLayout } from '../utils/load-address'
|
|
20
|
+
import { holderEditorOptions, setAddressing } from './load-layout-host'
|
|
21
|
+
|
|
22
|
+
const t = (key: string, fallback: string, opts: Record<string, unknown> = {}) =>
|
|
23
|
+
i18next.t(`label.load-layout.${key}`, { defaultValue: fallback, ...opts })
|
|
24
|
+
|
|
25
|
+
@customElement('ox-load-layout-panel')
|
|
26
|
+
export class OxLoadLayoutPanel extends localize(i18next)(LitElement) {
|
|
27
|
+
static styles = css`
|
|
28
|
+
:host {
|
|
29
|
+
display: block;
|
|
30
|
+
font-size: 12px;
|
|
31
|
+
}
|
|
32
|
+
h4 {
|
|
33
|
+
margin: 6px 0 2px;
|
|
34
|
+
font-size: 12px;
|
|
35
|
+
}
|
|
36
|
+
.row {
|
|
37
|
+
display: grid;
|
|
38
|
+
grid-template-columns: 40% 1fr;
|
|
39
|
+
align-items: center;
|
|
40
|
+
gap: 4px;
|
|
41
|
+
}
|
|
42
|
+
input,
|
|
43
|
+
select {
|
|
44
|
+
font-size: 12px;
|
|
45
|
+
min-width: 0;
|
|
46
|
+
}
|
|
47
|
+
`
|
|
48
|
+
|
|
49
|
+
/** 선택한 홀더 (LoadLayoutHolder) */
|
|
50
|
+
@property({ attribute: false }) holder: any
|
|
51
|
+
/** 도면 노드의 개별 설정 (state.slotLayout) */
|
|
52
|
+
@property({ attribute: false }) value: LoadLayout | undefined
|
|
53
|
+
|
|
54
|
+
render() {
|
|
55
|
+
if (!this.holder?.effectiveLayout) return nothing
|
|
56
|
+
const options = {
|
|
57
|
+
...holderEditorOptions(this.holder, this.value, t, commonIssueText),
|
|
58
|
+
renderExtra: (draft: LoadLayout, edit: (next: LoadLayout) => void) => this._renderAddressing(draft, edit)
|
|
59
|
+
}
|
|
60
|
+
return html`<ox-slot-layout-editor
|
|
61
|
+
.value=${this.value}
|
|
62
|
+
.options=${options}
|
|
63
|
+
@change=${this._apply}
|
|
64
|
+
@slot-layout-use-default=${this._useDefault}
|
|
65
|
+
></ox-slot-layout-editor>`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 공통 편집기의 change 는 여기서 받고, 속성 패널에는 이 요소의 change 로 다시 알린다 (value-key 가 이 요소에 있다) */
|
|
69
|
+
private _apply(e: Event) {
|
|
70
|
+
e.stopPropagation()
|
|
71
|
+
this.value = structuredClone((e.target as any).value)
|
|
72
|
+
this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 기본값 사용: 개별 설정을 지운다. 값을 비우는 편집과 다르다 */
|
|
76
|
+
private _useDefault(e: Event) {
|
|
77
|
+
e.stopPropagation()
|
|
78
|
+
this.value = undefined
|
|
79
|
+
this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 실시간 데이터에서 주소를 읽는 규칙 (3.6절). 공통 편집기의 renderExtra 자리에 그린다 */
|
|
83
|
+
private _renderAddressing(draft: LoadLayout, edit: (next: LoadLayout) => void) {
|
|
84
|
+
const a = draft.addressing
|
|
85
|
+
const set = (next: LoadAddressing | undefined) => edit(setAddressing(draft, next))
|
|
86
|
+
const text = (field: string, label: string, value: string | undefined, onChange: (v: string) => void, placeholder = '') => html`
|
|
87
|
+
<div class="row">
|
|
88
|
+
<label>${label}</label>
|
|
89
|
+
<input
|
|
90
|
+
data-field=${field}
|
|
91
|
+
.value=${value ?? ''}
|
|
92
|
+
placeholder=${placeholder}
|
|
93
|
+
@change=${(e: Event) => onChange((e.target as HTMLInputElement).value.trim())}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
`
|
|
97
|
+
return html`
|
|
98
|
+
<h4>${t('addressing', 'Address in live data')}</h4>
|
|
99
|
+
<div class="row">
|
|
100
|
+
<label>${t('addressing-by', 'Read by')}</label>
|
|
101
|
+
<select
|
|
102
|
+
data-field="addressing-by"
|
|
103
|
+
@change=${(e: Event) => {
|
|
104
|
+
const by = (e.target as HTMLSelectElement).value
|
|
105
|
+
set(
|
|
106
|
+
by === 'FIELDS'
|
|
107
|
+
? { by, tierField: 'TIER', slotField: 'SLOT' }
|
|
108
|
+
: by === 'PATTERN'
|
|
109
|
+
? { by, field: 'CARRIERLOC', pattern: '{slot}' }
|
|
110
|
+
: undefined
|
|
111
|
+
)
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<option value="" ?selected=${!a}>${t('addressing-none', 'Not read (all shown as address unknown)')}</option>
|
|
115
|
+
<option value="FIELDS" ?selected=${a?.by === 'FIELDS'}>${t('addressing-fields', 'Fields')}</option>
|
|
116
|
+
<option value="PATTERN" ?selected=${a?.by === 'PATTERN'}>${t('addressing-pattern', 'Location pattern')}</option>
|
|
117
|
+
</select>
|
|
118
|
+
</div>
|
|
119
|
+
${a?.by === 'FIELDS'
|
|
120
|
+
? html`${text('tier-field', t('tier-field', 'Tier field'), a.tierField, v => set({ ...a, tierField: v || undefined }), t('tier-field-empty', 'Empty = tier 1'))}
|
|
121
|
+
${text('slot-field', t('slot-field', 'Slot field'), a.slotField, v => set({ ...a, slotField: v }))}`
|
|
122
|
+
: nothing}
|
|
123
|
+
${a?.by === 'PATTERN'
|
|
124
|
+
? html`${text('field', t('field', 'Field'), a.field, v => set({ ...a, field: v }))}
|
|
125
|
+
${text('pattern', t('pattern-text', 'Pattern'), a.pattern, v => set({ ...a, pattern: v }), '{rack}-{slot:02}-{tier:02}')}`
|
|
126
|
+
: nothing}
|
|
127
|
+
`
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -16,6 +16,8 @@ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUti
|
|
|
16
16
|
import { RealObjectGroup, registerRealObjectFactory, MaterialBank } from '@hatiolab/things-scene'
|
|
17
17
|
import type { Component } from '@hatiolab/things-scene'
|
|
18
18
|
import { TRANSPORT_SURFACE_HEIGHT } from './machine-3d.js'
|
|
19
|
+
import { craneForkCount } from '../utils/crane-forks'
|
|
20
|
+
import { forkAnchorId } from '@hatiolab/figure-model'
|
|
19
21
|
|
|
20
22
|
const CLAMP_COLOR = 0x444444
|
|
21
23
|
const FORK_COLOR = 0x999999
|
|
@@ -28,6 +30,12 @@ export class Crane3D extends RealObjectGroup {
|
|
|
28
30
|
private forkGroup?: THREE.Group
|
|
29
31
|
private forkMastBaseY = 0
|
|
30
32
|
private forkMastH = 0
|
|
33
|
+
/** 포크별 Anchor 노드 (fork_1, fork_2). 캐리지(forkGroup) 아래에서 각자 뻗는다 */
|
|
34
|
+
private forkNodes = new Map<string, THREE.Group>()
|
|
35
|
+
/** 포크 판 윗면 높이 (포크 노드 로컬). Carrier 는 이 면에 놓인다 */
|
|
36
|
+
private forkSurfaceY = 0
|
|
37
|
+
/** 포크가 끝까지(100) 뻗었을 때의 거리 (+Z, 도면 y 방향) */
|
|
38
|
+
private forkReach = 0
|
|
31
39
|
|
|
32
40
|
get effectiveDepth(): number {
|
|
33
41
|
const { depth } = this.component.state
|
|
@@ -45,6 +53,35 @@ export class Crane3D extends RealObjectGroup {
|
|
|
45
53
|
this.forkGroup.position.y = this.forkMastBaseY + (clamped / 100) * this.forkMastH
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
/** 포크 n 을 0~100 만큼 뻗는다 (다른 포크는 그대로) */
|
|
57
|
+
applyForkExtend(fork: number, extend: number): void {
|
|
58
|
+
const node = this.forkNodes.get(forkAnchorId(fork))
|
|
59
|
+
if (!node) return
|
|
60
|
+
const clamped = Math.max(0, Math.min(100, Number(extend) || 0))
|
|
61
|
+
node.position.z = (clamped / 100) * this.forkReach
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 포크 Anchor 노드: 포크 판 윗면의 중심. 포크 노드 아래에 있어 포크가 오르내리고 뻗으면 함께 간다 */
|
|
65
|
+
private forkAnchors = new Map<string, THREE.Group>()
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A fork's anchor (common item contract 3.4): a node at the centre of its plate's top, under the fork, so what it
|
|
69
|
+
* holds goes up, down and out with it. Null when the crane has no such fork.
|
|
70
|
+
*/
|
|
71
|
+
forkAnchor(anchorId: string): THREE.Object3D | null {
|
|
72
|
+
const fork = this.forkNodes.get(anchorId)
|
|
73
|
+
if (!fork) return null
|
|
74
|
+
let node = this.forkAnchors.get(anchorId)
|
|
75
|
+
if (!node || node.parent !== fork) {
|
|
76
|
+
node = new THREE.Group()
|
|
77
|
+
node.name = `${anchorId}-anchor`
|
|
78
|
+
fork.add(node)
|
|
79
|
+
this.forkAnchors.set(anchorId, node)
|
|
80
|
+
}
|
|
81
|
+
node.position.set(0, this.forkSurfaceY, 0)
|
|
82
|
+
return node
|
|
83
|
+
}
|
|
84
|
+
|
|
48
85
|
private resolveColor(): number {
|
|
49
86
|
const comp = this.component as any
|
|
50
87
|
|
|
@@ -131,45 +168,60 @@ export class Crane3D extends RealObjectGroup {
|
|
|
131
168
|
// 일 때 mast 바닥 자리에. forkZ 0~100 에 따라 group.position.y 만 변경하면
|
|
132
169
|
// mast 따라 위/아래 이동. mesh 재구축 불필요.
|
|
133
170
|
const forkMat = MaterialBank.material('crane|fork', { roughness: 0.4, metalness: 0.5 }, FORK_COLOR)
|
|
171
|
+
const forkCount = craneForkCount((this.component.state as any).forkCount)
|
|
134
172
|
|
|
135
|
-
const forkGeometries: THREE.BufferGeometry[] = []
|
|
136
|
-
|
|
137
|
-
const plateW = w * 0.9
|
|
138
173
|
const plateH = d * 0.08
|
|
139
174
|
const plateD = pillarThick * 0.8
|
|
140
|
-
const centerPlate = new THREE.BoxGeometry(plateW, plateH, plateD)
|
|
141
|
-
centerPlate.translate(0, plateH / 2, 0) // local 0 = mast 바닥
|
|
142
|
-
forkGeometries.push(centerPlate)
|
|
143
|
-
|
|
144
175
|
const armW = pillarThick * 0.6
|
|
145
176
|
const armH = plateH * 0.4
|
|
146
177
|
const armD = h * 0.6
|
|
147
|
-
const armSpacing = w * 0.5
|
|
148
178
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
179
|
+
/* 포크 하나: 가로 판 + 판 앞뒤로 뻗은 팔 두 쌍. 로컬 0 = 판 바닥, 중심 x = 0 */
|
|
180
|
+
const buildFork = (plateW: number) => {
|
|
181
|
+
const parts: THREE.BufferGeometry[] = []
|
|
182
|
+
const plate = new THREE.BoxGeometry(plateW, plateH, plateD)
|
|
183
|
+
plate.translate(0, plateH / 2, 0)
|
|
184
|
+
parts.push(plate)
|
|
185
|
+
const armSpacing = plateW * 0.55
|
|
186
|
+
for (const zSign of [-1, 1]) {
|
|
187
|
+
for (const xSign of [-1, 1]) {
|
|
188
|
+
const arm = new THREE.BoxGeometry(armW, armH, armD)
|
|
189
|
+
arm.translate(xSign * (armSpacing / 2), armH / 2, zSign * (plateD / 2 + armD / 2))
|
|
190
|
+
parts.push(arm)
|
|
191
|
+
}
|
|
154
192
|
}
|
|
193
|
+
const mesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(parts), forkMat)
|
|
194
|
+
mesh.castShadow = true
|
|
195
|
+
return mesh
|
|
155
196
|
}
|
|
156
197
|
|
|
157
|
-
|
|
158
|
-
forkMesh.castShadow = true
|
|
159
|
-
|
|
198
|
+
// 캐리지: forkZ 로 오르내린다. 포크는 캐리지 안에서 각자 Z 로 뻗는다 (트윈 포크의 독립 이동)
|
|
160
199
|
const forkGroup = new THREE.Group()
|
|
161
|
-
forkGroup.add(forkMesh)
|
|
162
200
|
inner.add(forkGroup)
|
|
201
|
+
this.forkNodes.clear()
|
|
202
|
+
const forkW = forkCount === 2 ? w * 0.42 : w * 0.9
|
|
203
|
+
for (let i = 1; i <= forkCount; i++) {
|
|
204
|
+
const node = new THREE.Group()
|
|
205
|
+
node.name = forkAnchorId(i)
|
|
206
|
+
node.position.x = forkCount === 2 ? (i === 1 ? -1 : 1) * w * 0.24 : 0
|
|
207
|
+
node.add(buildFork(forkW))
|
|
208
|
+
forkGroup.add(node)
|
|
209
|
+
this.forkNodes.set(node.name, node)
|
|
210
|
+
}
|
|
163
211
|
this.forkGroup = forkGroup
|
|
164
212
|
this.forkMastBaseY = mastBaseY
|
|
213
|
+
this.forkSurfaceY = plateH
|
|
214
|
+
this.forkReach = h * 0.8
|
|
165
215
|
// forkZ=100 시점에 mast 의 *_위쪽 한계*. 포크 자체 두께 (plateH) 만큼은 마스트
|
|
166
216
|
// 안에 머무르도록 limit 보정 — 그렇지 않으면 forkZ=100 일 때 포크가 상단
|
|
167
217
|
// 클램프와 충돌. mastH * 0.92 로 약간 보수적 (= 마스트 본체 92% 까지만 이동).
|
|
168
218
|
this.forkMastH = mastH * 0.92
|
|
169
219
|
|
|
170
|
-
// 초기 forkZ 반영 — state
|
|
171
|
-
const
|
|
220
|
+
// 초기 forkZ·포크 뻗음 반영 — state 에 이미 있으면 그 위치에 배치
|
|
221
|
+
const state = this.component.state as any
|
|
222
|
+
const initialForkZ = Number(state.forkZ)
|
|
172
223
|
this.applyForkZ(Number.isFinite(initialForkZ) ? initialForkZ : 0)
|
|
224
|
+
for (let i = 1; i <= forkCount; i++) this.applyForkExtend(i, Number(state[`forkExtend${i}`]) || 0)
|
|
173
225
|
|
|
174
226
|
// ── 4. 상단 클램프 (천장 레일에 물리는 가이드부) ──
|
|
175
227
|
const topClampGeo = new THREE.BoxGeometry(clampW, clampH, clampD)
|
|
@@ -188,14 +240,16 @@ export class Crane3D extends RealObjectGroup {
|
|
|
188
240
|
/** build() 직전 호출. forkGroup 참조를 깨끗이 비워둔다 — 새 build 가 다시 set. */
|
|
189
241
|
clear() {
|
|
190
242
|
this.forkGroup = undefined
|
|
243
|
+
this.forkNodes.clear()
|
|
191
244
|
super.clear()
|
|
192
245
|
}
|
|
193
246
|
|
|
194
247
|
onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
|
|
195
|
-
if ('width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after) {
|
|
248
|
+
if ('width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after || 'forkCount' in after) {
|
|
196
249
|
this.update()
|
|
197
250
|
return
|
|
198
251
|
}
|
|
252
|
+
for (const fork of [1, 2]) if (`forkExtend${fork}` in after) this.applyForkExtend(fork, Number(after[`forkExtend${fork}`]) || 0)
|
|
199
253
|
// forkZ 는 mesh 재구축 없이 group.position.y 만 갱신. 같은 onchange 에서
|
|
200
254
|
// left/top 도 함께 변경될 수 있으므로 super 도 반드시 호출해 본체 transform
|
|
201
255
|
// 갱신 path (state.left/top → obj3d.position.x/z) 가 동작하게 함.
|