@operato/property-panel 10.0.0-beta.6 → 10.0.0-beta.60

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +395 -0
  2. package/dist/src/index.d.ts +4 -0
  3. package/dist/src/index.js +6 -0
  4. package/dist/src/index.js.map +1 -1
  5. package/dist/src/ox-property-panel.d.ts +7 -0
  6. package/dist/src/ox-property-panel.js +62 -34
  7. package/dist/src/ox-property-panel.js.map +1 -1
  8. package/dist/src/property-panel/abstract-property.js +11 -2
  9. package/dist/src/property-panel/abstract-property.js.map +1 -1
  10. package/dist/src/property-panel/data-binding/data-binding-mapper.js +11 -3
  11. package/dist/src/property-panel/data-binding/data-binding-mapper.js.map +1 -1
  12. package/dist/src/property-panel/data-binding/data-binding-popup.d.ts +63 -0
  13. package/dist/src/property-panel/data-binding/data-binding-popup.js +1414 -0
  14. package/dist/src/property-panel/data-binding/data-binding-popup.js.map +1 -0
  15. package/dist/src/property-panel/data-binding/data-binding.d.ts +1 -0
  16. package/dist/src/property-panel/data-binding/data-binding.js +37 -5
  17. package/dist/src/property-panel/data-binding/data-binding.js.map +1 -1
  18. package/dist/src/property-panel/effects/effects.js +1 -1
  19. package/dist/src/property-panel/effects/effects.js.map +1 -1
  20. package/dist/src/property-panel/effects/property-event-hover.d.ts +1 -1
  21. package/dist/src/property-panel/effects/property-event-tap.js +21 -1
  22. package/dist/src/property-panel/effects/property-event-tap.js.map +1 -1
  23. package/dist/src/property-panel/effects/property-event.d.ts +15 -7
  24. package/dist/src/property-panel/effects/property-event.js +17 -38
  25. package/dist/src/property-panel/effects/property-event.js.map +1 -1
  26. package/dist/src/property-panel/effects/property-shadow.js +14 -5
  27. package/dist/src/property-panel/effects/property-shadow.js.map +1 -1
  28. package/dist/src/property-panel/event-handlers/event-handlers-help.d.ts +23 -0
  29. package/dist/src/property-panel/event-handlers/event-handlers-help.js +356 -0
  30. package/dist/src/property-panel/event-handlers/event-handlers-help.js.map +1 -0
  31. package/dist/src/property-panel/event-handlers/event-handlers-mapper.d.ts +31 -0
  32. package/dist/src/property-panel/event-handlers/event-handlers-mapper.js +238 -0
  33. package/dist/src/property-panel/event-handlers/event-handlers-mapper.js.map +1 -0
  34. package/dist/src/property-panel/event-handlers/event-handlers-popup.d.ts +42 -0
  35. package/dist/src/property-panel/event-handlers/event-handlers-popup.js +375 -0
  36. package/dist/src/property-panel/event-handlers/event-handlers-popup.js.map +1 -0
  37. package/dist/src/property-panel/event-handlers/event-handlers.d.ts +54 -0
  38. package/dist/src/property-panel/event-handlers/event-handlers.js +410 -0
  39. package/dist/src/property-panel/event-handlers/event-handlers.js.map +1 -0
  40. package/dist/src/property-panel/shapes/ox-placeholder-convert-editor.d.ts +20 -0
  41. package/dist/src/property-panel/shapes/ox-placeholder-convert-editor.js +125 -0
  42. package/dist/src/property-panel/shapes/ox-placeholder-convert-editor.js.map +1 -0
  43. package/dist/src/property-panel/shapes/placeholder-convert.d.ts +34 -0
  44. package/dist/src/property-panel/shapes/placeholder-convert.js +117 -0
  45. package/dist/src/property-panel/shapes/placeholder-convert.js.map +1 -0
  46. package/dist/src/property-panel/shapes/shapes.js +28 -10
  47. package/dist/src/property-panel/shapes/shapes.js.map +1 -1
  48. package/dist/src/property-panel/specifics/specifics.d.ts +1 -0
  49. package/dist/src/property-panel/specifics/specifics.js +2 -1
  50. package/dist/src/property-panel/specifics/specifics.js.map +1 -1
  51. package/dist/src/property-panel/styles/styles.js +2 -0
  52. package/dist/src/property-panel/styles/styles.js.map +1 -1
  53. package/dist/src/property-panel/threed/property-material3d.js +8 -1
  54. package/dist/src/property-panel/threed/property-material3d.js.map +1 -1
  55. package/dist/src/property-panel/threed/property-scene3d.d.ts +34 -6
  56. package/dist/src/property-panel/threed/property-scene3d.js +332 -201
  57. package/dist/src/property-panel/threed/property-scene3d.js.map +1 -1
  58. package/dist/src/property-panel/threed/threed.js +8 -0
  59. package/dist/src/property-panel/threed/threed.js.map +1 -1
  60. package/dist/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +8 -7
  62. package/themes/grist-theme.css +1 -1
  63. package/translations/en.json +34 -1
  64. package/translations/ja.json +34 -1
  65. package/translations/ko.json +34 -1
  66. package/translations/ms.json +34 -1
  67. package/translations/zh.json +34 -1
@@ -3,49 +3,74 @@
3
3
  */
4
4
  import { __decorate } from "tslib";
5
5
  import '@operato/input/ox-input-color.js';
6
+ import '@operato/i18n/ox-i18n.js';
6
7
  import './property-material3d.js';
7
8
  import { css, html } from 'lit';
8
9
  import { property } from 'lit/decorators.js';
9
10
  import { PropertyGridStyles } from '@operato/styles/property-grid-styles.js';
10
11
  import { AbstractProperty } from '../abstract-property.js';
11
- const LIGHTING_PRESETS = {
12
- Neutral: {
13
- hemiIntensity: 0.6,
14
- dirLightEnabled: true,
15
- dirLightColor: '#ffffff',
16
- dirLightIntensity: 0.5
12
+ /**
13
+ * Environment별 기본 조명 프로파일.
14
+ * Environment 드롭다운에서 선택 시 sky 값과 함께 이 조명 필드들이 자동 적용된다.
15
+ * Light Mood(아래)는 이 위에 얹는 오버라이드 역할.
16
+ */
17
+ const ENVIRONMENT_DEFAULTS = {
18
+ // 실내
19
+ studio: {
20
+ hemiSkyColor: '#ffffff', hemiGroundColor: '#888888', hemiIntensity: 3.0,
21
+ dirLightEnabled: true, dirLightColor: '#ffffff', dirLightIntensity: 2.0,
22
+ dirLightFollowCamera: true
23
+ },
24
+ warehouse: {
25
+ hemiSkyColor: '#e0e4ea', hemiGroundColor: '#707880', hemiIntensity: 2.5,
26
+ dirLightEnabled: true, dirLightColor: '#f8fafc', dirLightIntensity: 1.8,
27
+ dirLightFollowCamera: true
28
+ },
29
+ factory: {
30
+ hemiSkyColor: '#d8dce2', hemiGroundColor: '#606870', hemiIntensity: 2.0,
31
+ dirLightEnabled: true, dirLightColor: '#e8eef2', dirLightIntensity: 2.0,
32
+ dirLightFollowCamera: true
17
33
  },
18
- Studio: {
19
- hemiIntensity: 0.5,
20
- dirLightEnabled: true,
21
- dirLightColor: '#ffffff',
22
- dirLightIntensity: 0.6
34
+ office: {
35
+ hemiSkyColor: '#ffffff', hemiGroundColor: '#aaaaaa', hemiIntensity: 3.0,
36
+ dirLightEnabled: true, dirLightColor: '#ffffff', dirLightIntensity: 1.5,
37
+ dirLightFollowCamera: true
23
38
  },
24
- Bright: {
25
- hemiIntensity: 0.8,
26
- dirLightEnabled: true,
27
- dirLightColor: '#ffffff',
28
- dirLightIntensity: 0.7
39
+ home: {
40
+ hemiSkyColor: '#ffe8c8', hemiGroundColor: '#7a5a40', hemiIntensity: 2.0,
41
+ dirLightEnabled: true, dirLightColor: '#ffd8a0', dirLightIntensity: 1.5,
42
+ dirLightFollowCamera: true
29
43
  },
30
- Warm: {
31
- hemiIntensity: 0.5,
32
- dirLightEnabled: true,
33
- dirLightColor: '#ffcc88',
34
- dirLightIntensity: 0.4
44
+ // 실외 (날씨)
45
+ sunny: {
46
+ hemiSkyColor: '#87ceeb', hemiGroundColor: '#8b7355', hemiIntensity: 2.0,
47
+ dirLightEnabled: true, dirLightColor: '#fff4cf', dirLightIntensity: 3.0,
48
+ dirLightFollowCamera: false, dirLightAzimuth: 135, dirLightElevation: 60
35
49
  },
36
- Cool: {
37
- hemiIntensity: 0.5,
38
- dirLightEnabled: true,
39
- dirLightColor: '#cce0ff',
40
- dirLightIntensity: 0.4
50
+ cloudy: {
51
+ hemiSkyColor: '#c8cbd0', hemiGroundColor: '#9ba1a8', hemiIntensity: 3.0,
52
+ dirLightEnabled: true, dirLightColor: '#e8e8e8', dirLightIntensity: 0.6,
53
+ dirLightFollowCamera: false, dirLightAzimuth: 120, dirLightElevation: 50
41
54
  },
42
- Flat: {
43
- hemiIntensity: 0.8,
44
- dirLightEnabled: false,
45
- dirLightColor: '#ffffff',
46
- dirLightIntensity: 0
55
+ rainy: {
56
+ hemiSkyColor: '#8a909a', hemiGroundColor: '#4a4f55', hemiIntensity: 1.5,
57
+ dirLightEnabled: true, dirLightColor: '#b8c0c8', dirLightIntensity: 0.3,
58
+ dirLightFollowCamera: false, dirLightAzimuth: 110, dirLightElevation: 40
47
59
  }
48
60
  };
61
+ /**
62
+ * Light Mood 프리셋 — Environment 기본값 위에 얹는 분위기 오버라이드.
63
+ * Environment(지금 씬이 어디에 있는가)와 독립적으로 작동하며 sky는 건드리지 않는다.
64
+ * 원자 조명 필드(hemi/dir)만 세팅.
65
+ */
66
+ const LIGHTING_PRESETS = {
67
+ Bright: { hemiIntensity: 3.0, dirLightEnabled: true, dirLightColor: '#ffffff', dirLightIntensity: 2.5 },
68
+ Dim: { hemiIntensity: 0.5, dirLightEnabled: true, dirLightColor: '#fff5e0', dirLightIntensity: 0.5 },
69
+ Warm: { hemiIntensity: 1.5, dirLightEnabled: true, dirLightColor: '#ffaa55', dirLightIntensity: 2.0 },
70
+ Cool: { hemiIntensity: 1.5, dirLightEnabled: true, dirLightColor: '#88bbff', dirLightIntensity: 2.0 },
71
+ Dramatic: { hemiIntensity: 0.3, dirLightEnabled: true, dirLightColor: '#ffffff', dirLightIntensity: 3.0 },
72
+ Flat: { hemiIntensity: 3.0, dirLightEnabled: false, dirLightColor: '#ffffff', dirLightIntensity: 0 }
73
+ };
49
74
  /**
50
75
  * Scene-level 3D settings for model-layer.
51
76
  * Includes 3D mode, camera, renderer, lighting, presets, and floor configuration.
@@ -58,19 +83,112 @@ export class PropertyScene3D extends AbstractProperty {
58
83
  render() {
59
84
  const value = this.value || {};
60
85
  return html `
61
- ${this._renderMode(value)} ${this._renderBookmarks(value)} ${this._renderRenderer(value)}
62
- ${this._renderSky(value)}
86
+ ${this._renderMode(value)} ${this._renderPlacement(value)}
87
+ ${this._renderCamera(value)}
88
+ ${this._renderRenderer(value)} ${this._renderSky(value)}
63
89
  ${this._renderHemisphereLight(value)} ${this._renderKeyLight(value)} ${this._renderLightingPresets()}
64
90
  ${this._renderFloor(value)}
65
91
  `;
66
92
  }
93
+ /**
94
+ * Camera 초기 설정 — Initial View(뷰 프리셋), FOV/Near/Far, 현재 카메라 저장 버튼.
95
+ * 사용자가 씬을 3D로 돌려본 후 "이 각도가 마음에 든다" 싶으면
96
+ * Save Current View로 현재 viewport의 position을 모델에 기록 → 다음 로드 시 같은 각도로 시작.
97
+ */
98
+ _renderCamera(value) {
99
+ var _a, _b, _c;
100
+ const views = [
101
+ { value: 'perspective', label: 'Perspective' },
102
+ { value: 'top', label: 'Top' },
103
+ { value: 'front', label: 'Front' },
104
+ { value: 'back', label: 'Back' },
105
+ { value: 'right', label: 'Right' },
106
+ { value: 'left', label: 'Left' }
107
+ ];
108
+ const currentView = value.initialCameraView || 'perspective';
109
+ const fov = Number((_a = value.fov) !== null && _a !== void 0 ? _a : 45);
110
+ const near = Number((_b = value.near) !== null && _b !== void 0 ? _b : 0.1);
111
+ const far = Number((_c = value.far) !== null && _c !== void 0 ? _c : 20000);
112
+ return html `
113
+ <fieldset>
114
+ <legend>Camera</legend>
115
+ <div class="property-grid">
116
+ <label>Initial View</label>
117
+ <select value-key="initialCameraView">
118
+ ${views.map(v => html `<option value=${v.value} ?selected=${currentView === v.value}>${v.label}</option>`)}
119
+ </select>
120
+
121
+ <label>FOV</label>
122
+ <input type="number" value-key="fov" step="1" min="1" max="170" .value=${String(fov)} />
123
+
124
+ <label>Near</label>
125
+ <input type="number" value-key="near" step="0.1" min="0.01" .value=${String(near)} />
126
+
127
+ <label>Far</label>
128
+ <input type="number" value-key="far" step="100" min="1" .value=${String(far)} />
129
+ </div>
130
+
131
+ <div class="camera-actions">
132
+ <button type="button" @click=${this._onSaveCameraView}>Save Current View</button>
133
+ </div>
134
+ </fieldset>
135
+ `;
136
+ }
137
+ /**
138
+ * 현재 3D viewport의 카메라 상태를 모델에 저장한다.
139
+ * scene의 getCameraState를 호출하여 view/cameraX/cameraY/cameraZ를 얻어 property-change로 전파.
140
+ */
141
+ _onSaveCameraView() {
142
+ var _a, _b, _c;
143
+ const cap = (_c = (_b = (_a = this.scene) === null || _a === void 0 ? void 0 : _a.rootContainer) === null || _b === void 0 ? void 0 : _b.model_layer) === null || _c === void 0 ? void 0 : _c._threeCapability;
144
+ if (!(cap === null || cap === void 0 ? void 0 : cap.getCameraState))
145
+ return;
146
+ const state = cap.getCameraState();
147
+ if (!state)
148
+ return;
149
+ this.dispatchEvent(new CustomEvent('property-change', {
150
+ bubbles: true,
151
+ composed: true,
152
+ detail: {
153
+ initialCameraView: state.view,
154
+ cameraX: state.cameraX,
155
+ cameraY: state.cameraY,
156
+ cameraZ: state.cameraZ
157
+ }
158
+ }));
159
+ }
160
+ /**
161
+ * Scene placement 모드 선택 — zPos 해석과 볼륨 origin 방향을 씬 전체에 일관 적용.
162
+ * floor : zPos=바닥 높이, 볼륨이 위로 쌓임 (공장/창고/건축)
163
+ * space : zPos=볼륨 중심 Y, 바닥 개념 없음 (우주/추상/전시)
164
+ * inverted: zPos=천장, 볼륨이 아래로 매달림 (천장 설비, 베타)
165
+ */
166
+ _renderPlacement(value) {
167
+ const options = [
168
+ { value: 'floor', label: 'Floor (default)' },
169
+ { value: 'space', label: 'Space' },
170
+ { value: 'inverted', label: 'Inverted (beta)' }
171
+ ];
172
+ const current = value.placement || 'floor';
173
+ return html `
174
+ <fieldset>
175
+ <legend>Placement</legend>
176
+ <div class="property-grid">
177
+ <label>Mode</label>
178
+ <select value-key="placement">
179
+ ${options.map(o => html `<option value=${o.value} ?selected=${current === o.value}>${o.label}</option>`)}
180
+ </select>
181
+ </div>
182
+ </fieldset>
183
+ `;
184
+ }
67
185
  _renderMode(value) {
68
186
  return html `
69
187
  <fieldset>
70
188
  <legend>3D Mode</legend>
71
189
  <div class="property-grid">
72
190
  <input id="cb-threed" type="checkbox" value-key="threed" .checked=${!!value.threed} />
73
- <label for="cb-threed">Enable 3D</label>
191
+ <label for="cb-threed"><ox-i18n msgid="label.start-in-3d">Start in 3D</ox-i18n></label>
74
192
 
75
193
  <label>Viewer Auto</label>
76
194
  <select value-key="cameraAutoPlay">
@@ -82,87 +200,21 @@ export class PropertyScene3D extends AbstractProperty {
82
200
  ${o.label}
83
201
  </option>`)}
84
202
  </select>
203
+
204
+ <input
205
+ id="cb-floor-constraint"
206
+ type="checkbox"
207
+ value-key="cameraFloorConstraint"
208
+ .checked=${!!value.cameraFloorConstraint}
209
+ />
210
+ <label for="cb-floor-constraint"><ox-i18n msgid="label.keep-above-ground">Keep Above Ground</ox-i18n></label>
85
211
  </div>
86
212
  </fieldset>
87
213
  `;
88
214
  }
89
- _renderBookmarks(value) {
90
- const bookmarks = value.cameraBookmarks || [];
91
- return html `
92
- <fieldset>
93
- <legend>Bookmarks</legend>
94
- <div class="property-grid">
95
- <div class="bookmark-slots">
96
- ${[1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => {
97
- const filled = !!bookmarks[i];
98
- return html `
99
- <div
100
- class="bookmark-slot ${filled ? 'filled' : ''}"
101
- title=${filled ? 'Click: Go / Long-click: Overwrite / Right-click: Delete' : 'Long-click: Save current camera'}
102
- @click=${(e) => { if (filled) {
103
- this._bookmarkAction('navigate', i);
104
- const t = e.currentTarget;
105
- t.classList.add('saved');
106
- setTimeout(() => t.classList.remove('saved'), 300);
107
- } }}
108
- @contextmenu=${(e) => { e.preventDefault(); if (filled)
109
- this._bookmarkAction('clear', i); }}
110
- @mousedown=${(e) => this._onBookmarkMouseDown(e, i)}
111
- @mouseup=${() => this._onBookmarkMouseUp()}
112
- @mouseleave=${() => this._onBookmarkMouseUp()}
113
- >${i}</div>
114
- `;
115
- })}
116
- </div>
117
- </div>
118
- </fieldset>
119
- `;
120
- }
121
- _onBookmarkMouseDown(e, index) {
122
- if (e.button !== 0)
123
- return;
124
- const target = e.currentTarget;
125
- this._pressTarget = target;
126
- // 롱클릭 시작: filling 애니메이션
127
- target.classList.add('saving');
128
- this._longPressTimer = setTimeout(() => {
129
- this._longPressTimer = undefined;
130
- target.classList.remove('saving');
131
- this._bookmarkAction('save', index);
132
- // 저장 완료 피드백
133
- target.classList.add('saved');
134
- setTimeout(() => target.classList.remove('saved'), 300);
135
- }, 600);
136
- }
137
- _onBookmarkMouseUp() {
138
- if (this._longPressTimer) {
139
- clearTimeout(this._longPressTimer);
140
- this._longPressTimer = undefined;
141
- }
142
- if (this._pressTarget) {
143
- this._pressTarget.classList.remove('saving');
144
- this._pressTarget = undefined;
145
- }
146
- }
147
- _bookmarkAction(action, index) {
148
- var _a;
149
- const root = (_a = this.scene) === null || _a === void 0 ? void 0 : _a.root;
150
- const cap = root === null || root === void 0 ? void 0 : root._threeCapability;
151
- if (!cap)
152
- return;
153
- if (action === 'save') {
154
- cap.saveCameraToSlot(index);
155
- }
156
- else if (action === 'navigate') {
157
- cap.animateToSlot(index);
158
- }
159
- else if (action === 'clear') {
160
- cap.bookmarkManager.clearSlot(index);
161
- root.set('cameraBookmarks', cap.bookmarkManager.exportSlots());
162
- }
163
- this.requestUpdate();
164
- }
165
215
  _renderRenderer(value) {
216
+ var _a;
217
+ const exposure = Number((_a = value.exposure) !== null && _a !== void 0 ? _a : 1.2);
166
218
  return html `
167
219
  <fieldset>
168
220
  <legend>Renderer</legend>
@@ -180,27 +232,62 @@ export class PropertyScene3D extends AbstractProperty {
180
232
  ${o.label}
181
233
  </option>`)}
182
234
  </select>
235
+
236
+ <label>Exposure</label>
237
+ <div class="range-with-value">
238
+ <input
239
+ type="range"
240
+ min="0.3"
241
+ max="2.5"
242
+ step="0.05"
243
+ value-key="exposure"
244
+ .value=${String(exposure)}
245
+ />
246
+ <span>${exposure.toFixed(2)}</span>
247
+ </div>
183
248
  </div>
184
249
  </fieldset>
185
250
  `;
186
251
  }
252
+ /**
253
+ * Environment 드롭다운 — 씬의 공간적 맥락을 정의.
254
+ * 선택 시 sky 값과 함께 ENVIRONMENT_DEFAULTS의 기본 조명까지 한 번에 적용한다.
255
+ * "원클릭 전체 look" — 사용자가 원하면 Light Mood/원자 컨트롤로 위에 얹어 조정.
256
+ */
187
257
  _renderSky(value) {
188
- const skyOptions = [
189
- { value: '', label: 'None' },
190
- { value: 'color', label: 'Color' },
191
- { value: 'studio', label: 'Studio' },
192
- { value: 'warehouse', label: 'Warehouse' },
193
- { value: 'factory', label: 'Factory' },
194
- { value: 'office', label: 'Office' },
195
- { value: 'home', label: 'Home' },
258
+ const envOptions = [
259
+ { value: '', label: 'None', group: 'basic' },
260
+ { value: 'color', label: 'Color', group: 'basic' },
261
+ { value: 'studio', label: 'Studio', group: 'indoor' },
262
+ { value: 'warehouse', label: 'Warehouse', group: 'indoor' },
263
+ { value: 'factory', label: 'Factory', group: 'indoor' },
264
+ { value: 'office', label: 'Office', group: 'indoor' },
265
+ { value: 'home', label: 'Home', group: 'indoor' },
266
+ { value: 'sunny', label: 'Sunny', group: 'outdoor' },
267
+ { value: 'cloudy', label: 'Cloudy', group: 'outdoor' },
268
+ { value: 'rainy', label: 'Rainy', group: 'outdoor' }
196
269
  ];
197
270
  return html `
198
271
  <fieldset>
199
- <legend>Sky</legend>
272
+ <legend>Environment</legend>
200
273
  <div class="property-grid">
201
274
  <label>Preset</label>
202
- <select value-key="sky">
203
- ${skyOptions.map(o => html `<option value=${o.value} ?selected=${(value.sky || '') === o.value}>${o.label}</option>`)}
275
+ <select @change=${this._onEnvironmentChange}>
276
+ <optgroup label="Basic">
277
+ ${envOptions
278
+ .filter(o => o.group === 'basic')
279
+ .map(o => html `<option value=${o.value} ?selected=${(value.sky || '') === o.value}>${o.label}</option>`)}
280
+ </optgroup>
281
+ <optgroup label="Indoor">
282
+ ${envOptions
283
+ .filter(o => o.group === 'indoor')
284
+ .map(o => html `<option value=${o.value} ?selected=${(value.sky || '') === o.value}>${o.label}</option>`)}
285
+ </optgroup>
286
+ <optgroup label="Outdoor">
287
+ ${envOptions
288
+ .filter(o => o.group === 'outdoor')
289
+ .map(o => html `<option value=${o.value} ?selected=${(value.sky || '') === o.value}>${o.label}</option>`)}
290
+ </optgroup>
204
291
  </select>
205
292
 
206
293
  ${value.sky === 'color'
@@ -213,12 +300,36 @@ export class PropertyScene3D extends AbstractProperty {
213
300
  </fieldset>
214
301
  `;
215
302
  }
303
+ /**
304
+ * Environment 선택 핸들러 — sky 필드만 바꾸는 게 아니라 ENVIRONMENT_DEFAULTS에
305
+ * 정의된 hemi/dir 기본 조명까지 한 번의 property-change로 함께 적용한다.
306
+ */
307
+ _onEnvironmentChange(e) {
308
+ const select = e.target;
309
+ const sky = select.value;
310
+ const detail = { sky };
311
+ const defaults = ENVIRONMENT_DEFAULTS[sky];
312
+ if (defaults) {
313
+ Object.assign(detail, defaults);
314
+ }
315
+ this.dispatchEvent(new CustomEvent('property-change', {
316
+ bubbles: true,
317
+ composed: true,
318
+ detail
319
+ }));
320
+ }
216
321
  _renderHemisphereLight(value) {
217
322
  var _a, _b;
218
323
  return html `
219
324
  <fieldset>
220
325
  <legend>Hemisphere Light</legend>
221
326
  <div class="property-grid">
327
+ <label>Sky</label>
328
+ <ox-input-color value-key="hemiSkyColor" .value=${value.hemiSkyColor || '#ffffff'}></ox-input-color>
329
+
330
+ <label>Ground</label>
331
+ <ox-input-color value-key="hemiGroundColor" .value=${value.hemiGroundColor || '#444444'}></ox-input-color>
332
+
222
333
  <label>Intensity</label>
223
334
  <div class="range-with-value">
224
335
  <input
@@ -236,7 +347,9 @@ export class PropertyScene3D extends AbstractProperty {
236
347
  `;
237
348
  }
238
349
  _renderKeyLight(value) {
239
- var _a, _b;
350
+ var _a, _b, _c, _d, _e, _f, _g, _h;
351
+ const followCamera = value.dirLightFollowCamera !== false;
352
+ const shadowEnabled = value.dirShadowEnabled !== false;
240
353
  return html `
241
354
  <fieldset>
242
355
  <legend>Key Light</legend>
@@ -264,22 +377,102 @@ export class PropertyScene3D extends AbstractProperty {
264
377
  />
265
378
  <span>${((_b = value.dirLightIntensity) !== null && _b !== void 0 ? _b : 0.5).toFixed(1)}</span>
266
379
  </div>
380
+
381
+ <input
382
+ id="cb-dirlight-follow"
383
+ type="checkbox"
384
+ value-key="dirLightFollowCamera"
385
+ .checked=${followCamera}
386
+ />
387
+ <label for="cb-dirlight-follow">Follow Camera</label>
388
+
389
+ ${!followCamera
390
+ ? html `
391
+ <label>Azimuth</label>
392
+ <div class="range-with-value">
393
+ <input
394
+ type="range"
395
+ min="0"
396
+ max="360"
397
+ step="5"
398
+ value-key="dirLightAzimuth"
399
+ .value=${String((_c = value.dirLightAzimuth) !== null && _c !== void 0 ? _c : 30)}
400
+ />
401
+ <span>${Math.round(((_d = value.dirLightAzimuth) !== null && _d !== void 0 ? _d : 30))}°</span>
402
+ </div>
403
+
404
+ <label>Elevation</label>
405
+ <div class="range-with-value">
406
+ <input
407
+ type="range"
408
+ min="0"
409
+ max="90"
410
+ step="5"
411
+ value-key="dirLightElevation"
412
+ .value=${String((_e = value.dirLightElevation) !== null && _e !== void 0 ? _e : 45)}
413
+ />
414
+ <span>${Math.round(((_f = value.dirLightElevation) !== null && _f !== void 0 ? _f : 45))}°</span>
415
+ </div>
416
+ `
417
+ : ''}
418
+
419
+ <input
420
+ id="cb-dirlight-shadow"
421
+ type="checkbox"
422
+ value-key="dirShadowEnabled"
423
+ .checked=${shadowEnabled}
424
+ />
425
+ <label for="cb-dirlight-shadow">Cast Shadow</label>
426
+
427
+ ${shadowEnabled
428
+ ? html `
429
+ <label>Shadow Bias</label>
430
+ <div class="range-with-value">
431
+ <input
432
+ type="range"
433
+ min="-0.01"
434
+ max="0.01"
435
+ step="0.0001"
436
+ value-key="dirShadowBias"
437
+ .value=${String((_g = value.dirShadowBias) !== null && _g !== void 0 ? _g : -0.0005)}
438
+ />
439
+ <span>${((_h = value.dirShadowBias) !== null && _h !== void 0 ? _h : -0.0005).toFixed(4)}</span>
440
+ </div>
441
+ `
442
+ : ''}
267
443
  </div>
268
444
  </fieldset>
269
445
  `;
270
446
  }
447
+ /**
448
+ * Light Mood — Environment가 설정한 기본 조명 위에 얹는 분위기 오버라이드.
449
+ * sky는 건드리지 않고 hemi/dir 값만 적용. Environment와 독립적으로 조합 가능.
450
+ */
271
451
  _renderLightingPresets() {
272
452
  return html `
273
453
  <fieldset>
274
- <legend>Lighting Presets</legend>
454
+ <legend>Light Mood</legend>
275
455
  <div class="property-grid">
276
- <div class="preset-buttons">
277
- ${Object.entries(LIGHTING_PRESETS).map(([name, values]) => html `<button @click=${() => this._applyLightingPreset(values)}>${name}</button>`)}
278
- </div>
456
+ <label>Apply</label>
457
+ <select @change=${this._onPresetChange}>
458
+ <option value="" selected>Select...</option>
459
+ ${Object.keys(LIGHTING_PRESETS).map(name => html `<option value=${name}>${name}</option>`)}
460
+ </select>
279
461
  </div>
280
462
  </fieldset>
281
463
  `;
282
464
  }
465
+ _onPresetChange(e) {
466
+ const select = e.target;
467
+ const name = select.value;
468
+ if (!name)
469
+ return;
470
+ const preset = LIGHTING_PRESETS[name];
471
+ if (preset)
472
+ this._applyLightingPreset(preset);
473
+ // 동일 preset을 다시 선택할 수 있도록 초기값으로 리셋
474
+ select.value = '';
475
+ }
283
476
  _renderFloor(value) {
284
477
  return html `
285
478
  <property-material3d
@@ -309,30 +502,6 @@ export class PropertyScene3D extends AbstractProperty {
309
502
  PropertyScene3D.styles = [
310
503
  PropertyGridStyles,
311
504
  css `
312
- .preset-buttons {
313
- grid-column: 1 / -1;
314
- display: flex;
315
- flex-wrap: wrap;
316
- gap: 4px;
317
- padding: 2px 0;
318
- }
319
-
320
- .preset-buttons button {
321
- flex: 1;
322
- min-width: 60px;
323
- padding: 4px 6px;
324
- border: 1px solid var(--md-sys-color-outline, #ccc);
325
- border-radius: 4px;
326
- background: var(--md-sys-color-surface, #fff);
327
- color: var(--md-sys-color-on-surface, #333);
328
- font-size: 11px;
329
- cursor: pointer;
330
- }
331
-
332
- .preset-buttons button:hover {
333
- background: var(--md-sys-color-primary-container, #e0e0e0);
334
- }
335
-
336
505
  .range-with-value {
337
506
  grid-column: 9 / -1;
338
507
  display: flex;
@@ -354,62 +523,24 @@ PropertyScene3D.styles = [
354
523
  color: var(--md-sys-color-on-secondary-container);
355
524
  }
356
525
 
357
- .bookmark-slots {
358
- grid-column: 1 / -1;
359
- display: grid;
360
- grid-template-columns: repeat(9, 1fr);
361
- gap: 2px;
362
- padding: 4px 0 0;
363
- }
364
-
365
- .bookmark-slot {
526
+ .camera-actions {
366
527
  display: flex;
367
- align-items: center;
368
- justify-content: center;
369
- height: 24px;
370
- border-radius: 3px;
371
- font-size: 11px;
372
- font-weight: 600;
373
- cursor: pointer;
374
- color: var(--md-sys-color-outline, #999);
375
- background: var(--md-sys-color-surface-variant, #f0f0f0);
376
- border: 1px solid transparent;
377
- user-select: none;
378
- transition: all 0.12s ease;
379
- }
380
-
381
- .bookmark-slot:hover {
382
- background: var(--md-sys-color-secondary-container, #e0e0e0);
383
- }
384
-
385
- .bookmark-slot.filled {
386
- color: var(--md-sys-color-primary, #6750A4);
387
- background: var(--md-sys-color-primary-container, #EADDFF);
388
- border-color: var(--md-sys-color-primary, #6750A4);
389
- font-weight: 700;
390
- }
391
-
392
- .bookmark-slot:active {
393
- transform: scale(0.9);
394
- }
395
-
396
- .bookmark-slot.saving {
397
- animation: bookmark-fill 0.6s linear forwards;
398
- }
399
-
400
- @keyframes bookmark-fill {
401
- from { box-shadow: inset 24px 0 0 0 transparent; }
402
- to { box-shadow: inset 24px 0 0 0 var(--md-sys-color-primary-container, #EADDFF); }
528
+ justify-content: flex-end;
529
+ margin-top: 6px;
403
530
  }
404
531
 
405
- .bookmark-slot.saved {
406
- animation: bookmark-saved 0.3s ease;
532
+ .camera-actions button {
533
+ font-size: 12px;
534
+ padding: 4px 10px;
535
+ border: 1px solid var(--md-sys-color-outline, #c4c7c5);
536
+ border-radius: 4px;
537
+ background: var(--md-sys-color-surface, #fff);
538
+ color: var(--md-sys-color-on-surface, #1c1b1f);
539
+ cursor: pointer;
407
540
  }
408
541
 
409
- @keyframes bookmark-saved {
410
- 0% { transform: scale(1); }
411
- 50% { transform: scale(1.2); }
412
- 100% { transform: scale(1); }
542
+ .camera-actions button:hover {
543
+ background: var(--md-sys-color-surface-variant, #e7e0ec);
413
544
  }
414
545
  `
415
546
  ];