PageSourceSearch

https://boga-tec.co.kr/skin-skin3/layout/basic/js/boga-interior.js?v=20260604a

js boga-tec.co.kr collected 2026-10-08 14:57:18 UTC 37,350 bytes, 960 lines download raw bytes

1/** ============================================================
2 *  BOGA9 Interior Studio — Canvas 13-Layer Recoloring Engine
3 *  Vanilla JS, no jQuery dependency
4 *  ============================================================ */
5(function () {
6  'use strict';
7
8  /* ----------------------------------------------------------
9     CONSTANTS
10     ---------------------------------------------------------- */
11  var CANVAS_W = 2000, CANVAS_H = 1500;
12  // Stacking: back→front. Floor(001) moved before door_c(002) so it doesn't cover it.
13  // 017 replaces 016 in-place (same image, new file). 010 split into 010-1..4 at top.
14  var LAYER_ORDER = [13, 14, 7, 11, 6, 8, 1, 2, 3, 5, 4, 15, 12, 17, 9, 21, 22, 23, 24];
15  var FIXED_LAYERS = [13];
16  var SEATBELT_LAYER = 11;
17  var ASSET_BASE = '/skin-skin3/SkinImg/configurator/interior/';
18  var LAYER_IMG_VERSION = '20260507b';  // bust browser cache when layer webp is replaced
19  // Per-layer file slug override (skips zero-padded default).
20  // Use when a layer asset is replaced and the CDN would otherwise serve the cached old name,
21  // or when a logical layer ID maps to a non-numeric file name (e.g. 010 split into 010-1..4).
22  var LAYER_FILE_OVERRIDE = {
23    2:  '002-1',
24    21: '010-1',
25    22: '010-2',
26    23: '010-3',
27    24: '010-4'
28    // 17 → uses default '017'
29  };
30
31  var COLOR_PRESETS = [
32    { id: 'white',  name: 'Snow White',    hex: '#F5F3EF' },
33    { id: 'ivory',  name: '바닐라',         hex: '#E8DEC8' },
34    { id: 'sand',   name: 'Warm Sand',     hex: '#D4B896' },
35    { id: 'cognac', name: '브라운',         hex: '#C08B5C' },
36    { id: 'brown',  name: 'Saddle Brown',  hex: '#8B5E3C' },
37    { id: 'mocha',  name: 'Dark Mocha',    hex: '#4A3728' },
38    { id: 'navy',   name: 'Midnight Navy', hex: '#1E2A45' },
39    { id: 'black',  name: 'Obsidian',      hex: '#1A1A1E' }
40  ];
41
42  var OPTION_MAP = {
43    seat:    { layers: [9, 21, 22, 12, 17], label: '시트 컬러', defaultColor: 'ivory' },
44    seat_accent: { layers: [15, 23, 24], label: '시트 배색',  defaultColor: 'ivory' },
45    seatbelt:{ layers: [11],        label: '안전벨트',     defaultColor: 'ivory' },
46    floor:   { layers: [1],         label: '바닥 플로어',   defaultPattern: '003' },
47    handle:  { layers: [8],         label: '손잡이 트리밍', defaultColor: 'ivory' },
48    door_a:  { layers: [4],         label: '도어 패널 A',  defaultColor: 'ivory' },
49    door_b:  { layers: [3],         label: '도어 패널 B',  defaultColor: 'ivory' },
50    door_c:  { layers: [2],         label: '도어 패널 C',  defaultColor: 'ivory' },
51    luggage: { layers: [5],         label: '러기지',       defaultColor: 'ivory' },
52    ceiling: { layers: [7],         label: '천정 패브릭',   defaultColor: 'white' },
53    ceiling_mold: { layers: [14],  label: '천정 몰딩',    defaultColor: 'white' },
54    blind:   { layers: [6],         label: '블라인드 커튼', defaultColor: 'ivory' }
55  };
56
57  var FLOOR_PATTERNS = [
58    { id: '003', name: '우드' },
59    { id: '005', name: '화이트' },
60    { id: '007', name: '블랙' }
61  ];
62
63  var THEMES = [
64    { id: 'classic', name: 'Classic Ivory',
65      seat: 'ivory', seatbelt: 'ivory', floor: 'marble_white', handle: 'brown', door_a: 'ivory', door_b: 'ivory', door_c: 'ivory', luggage: 'ivory', ceiling: 'white', blind: 'ivory' },
66    { id: 'mocha', name: 'Dark Mocha',
67      seat: 'mocha', seatbelt: 'mocha', floor: 'wood_dark', handle: 'black', door_a: 'mocha', door_b: 'mocha', door_c: 'mocha', luggage: 'mocha', ceiling: 'mocha', blind: 'mocha' },
68    { id: 'cognac', name: 'Cognac Luxe',
69      seat: 'cognac', seatbelt: 'sand', floor: 'wood_light', handle: 'cognac', door_a: 'sand', door_b: 'sand', door_c: 'sand', luggage: 'sand', ceiling: 'ivory', blind: 'sand' },
70    { id: 'navy', name: 'Navy Night',
71      seat: 'navy', seatbelt: 'navy', floor: 'marble_dark', handle: 'black', door_a: 'navy', door_b: 'navy', door_c: 'navy', luggage: 'navy', ceiling: 'black', blind: 'navy' }
72  ];
73
74  // Preset palettes — reviewed against /customBoga/기본 제공 색상 도감/ images (2026-04-16)
75  var INTERIOR_PRESETS = [
76    { id: 'brown_two',    name: '브라운 투톤',
77      seat: '#C08B5C', seat_accent: '#E8DEC8', seatbelt: '#E8DEC8', floor: '005',
78      handle: '#C08B5C', door_a: '#C08B5C', door_b: '#E8DEC8', door_c: '#C08B5C',
79      luggage: '#C08B5C', ceiling: '#C08B5C', ceiling_mold: '#E8DEC8', blind: '#C08B5C' },
80    { id: 'vanilla_two',  name: '바닐라 투톤',
81      seat: '#E8DEC8', seat_accent: '#C08B5C', seatbelt: '#C08B5C', floor: '005',
82      handle: '#E8DEC8', door_a: '#E8DEC8', door_b: '#C08B5C', door_c: '#E8DEC8',
83      luggage: '#E8DEC8', ceiling: '#E8DEC8', ceiling_mold: '#C08B5C', blind: '#E8DEC8' },
84    { id: 'full_black',   name: '풀 블랙',
85      seat: '#1A1A1E', seat_accent: '#1A1A1E', seatbelt: '#1A1A1E', floor: '005',
86      handle: '#1A1A1E', door_a: '#1A1A1E', door_b: '#1A1A1E', door_c: '#1A1A1E',
87      luggage: '#1A1A1E', ceiling: '#1A1A1E', ceiling_mold: '#1A1A1E', blind: '#1A1A1E' },
88    { id: 'full_vanilla', name: '풀 바닐라',
89      seat: '#E8DEC8', seat_accent: '#E8DEC8', seatbelt: '#E8DEC8', floor: '005',
90      handle: '#E8DEC8', door_a: '#E8DEC8', door_b: '#E8DEC8', door_c: '#E8DEC8',
91      luggage: '#E8DEC8', ceiling: '#E8DEC8', ceiling_mold: '#E8DEC8', blind: '#E8DEC8' },
92    { id: 'vanilla_black',name: '바닐라 블랙',
93      seat: '#E8DEC8', seat_accent: '#E8DEC8', seatbelt: '#E8DEC8', floor: '005',
94      handle: '#1A1A1E', door_a: '#1A1A1E', door_b: '#E8DEC8', door_c: '#1A1A1E',
95      luggage: '#1A1A1E', ceiling: '#1A1A1E', ceiling_mold: '#1A1A1E', blind: '#1A1A1E' }
96  ];
97
98  // Skyview roof themes
99  var ROOF_BASE = ASSET_BASE + 'roof/';
100  var SKYVIEW_THEMES = [
101    { id: 'default', name: '기본 하늘',  type: 'original' },
102    { id: 'roof_1',  name: '자연',      type: 'image', src: ROOF_BASE + 'roof_1.webp' },
103    { id: 'roof_2',  name: '오로라',    type: 'image', src: ROOF_BASE + 'roof_2.webp' },
104    { id: 'roof_3',  name: '설산',      type: 'image', src: ROOF_BASE + 'roof_3.webp' },
105    { id: 'roof_4',  name: '시어터',    type: 'image', src: ROOF_BASE + 'roof_4.webp' },
106    { id: 'roof_5',  name: '앰비언트',  type: 'image', src: ROOF_BASE + 'roof_5.webp' }
107  ];
108
109  // Option order for panel rendering (floor handled separately)
110  var COLOR_OPTIONS = ['seat', 'seat_accent', 'seatbelt', 'handle', 'door_a', 'door_b', 'door_c', 'luggage', 'ceiling', 'ceiling_mold', 'blind'];
111
112  /* ----------------------------------------------------------
113     STATE
114     ---------------------------------------------------------- */
115  var state = {
116    seat: '#E8DEC8',
117    seat_accent: '#E8DEC8',
118    seatbelt: '#E8DEC8',
119    floor: '003',
120    handle: '#E8DEC8',
121    door_a: '#E8DEC8',
122    door_b: '#E8DEC8',
123    door_c: '#E8DEC8',
124    luggage: '#E8DEC8',
125    ceiling: '#F5F3EF',
126    ceiling_mold: '#F5F3EF',
127    blind: '#E8DEC8',
128    skyview: 'default',
129    loadedCount: 0,
130    activePickerOption: null
131  };
132
133  /* ----------------------------------------------------------
134     CACHE
135     ---------------------------------------------------------- */
136  var originalImageData = {};
137  var offscreenCanvases = {};
138  var layerLumStats = {};   // per-layer {min, max, range} for dark-target recoloring
139  var baseCompositeCanvas;  // original full composite (backdrop)
140  var skyviewAlphaMask;     // layer 013 alpha channel cache
141  var skyviewOriginalData;  // layer 013 original pixel data
142  var seatbeltOriginalData; // layer 011 original for restoration (light seat colors)
143  var mainCanvas, mainCtx;
144
145  /* ----------------------------------------------------------
146     INIT
147     ---------------------------------------------------------- */
148  document.addEventListener('DOMContentLoaded', init);
149
150  function init() {
151    mainCanvas = document.getElementById('intCanvas');
152    if (!mainCanvas) return;
153    mainCanvas.width = CANVAS_W;
154    mainCanvas.height = CANVAS_H;
155    mainCtx = mainCanvas.getContext('2d');
156    cleanTextNodes();
157    loadAllLayers();
158  }
159
160  /** Strip stray text nodes (Cafe24 optimizer injects  ) */
161  function cleanTextNodes() {
162    document.querySelectorAll('.int-skyview__item').forEach(function (el) {
163      for (var i = el.childNodes.length - 1; i >= 0; i--) {
164        if (el.childNodes[i].nodeType === 3) el.removeChild(el.childNodes[i]);
165      }
166    });
167  }
168
169  /* ----------------------------------------------------------
170     LAYER LOADING
171     ---------------------------------------------------------- */
172  function loadAllLayers() {
173    var total = LAYER_ORDER.length;
174    var loaded = 0;
175
176    LAYER_ORDER.forEach(function (id) {
177      var img = new Image();
178      var padded = String(id).padStart(3, '0');
179      var fileSlug = LAYER_FILE_OVERRIDE[id] || padded;
180      img.src = ASSET_BASE + 'layers/' + fileSlug + '.webp?v=' + LAYER_IMG_VERSION;
181
182      img.onload = function () {
183        var osc = document.createElement('canvas');
184        osc.width = CANVAS_W;
185        osc.height = CANVAS_H;
186        var ctx = osc.getContext('2d');
187        ctx.drawImage(img, 0, 0, CANVAS_W, CANVAS_H);
188        offscreenCanvases[id] = osc;
189        // Cache original pixel data for colorable layers
190        if (FIXED_LAYERS.indexOf(id) === -1) {
191          originalImageData[id] = ctx.getImageData(0, 0, CANVAS_W, CANVAS_H);
192          // Pre-compute luminance stats for dark-target recoloring
193          var ld = originalImageData[id].data;
194          var lMin = 255, lMax = 0;
195          for (var li = 0; li < ld.length; li += 4) {
196            if (ld[li + 3] === 0) continue;
197            var ll = ld[li] * 0.299 + ld[li + 1] * 0.587 + ld[li + 2] * 0.114;
198            if (ll < lMin) lMin = ll;
199            if (ll > lMax) lMax = ll;
200          }
201          layerLumStats[id] = { min: lMin, max: lMax, range: Math.max(lMax - lMin, 1) };
202        }
203        // Cache seatbelt original for restoration when seat is light
204        if (id === SEATBELT_LAYER) {
205          seatbeltOriginalData = new Uint8ClampedArray(originalImageData[id].data);
206        }
207        // Cache layer 013 alpha mask for skyview swap
208        if (id === 13) {
209          var fullData = ctx.getImageData(0, 0, CANVAS_W, CANVAS_H);
210          skyviewOriginalData = new Uint8ClampedArray(fullData.data);
211          skyviewAlphaMask = new Uint8Array(CANVAS_W * CANVAS_H);
212          for (var a = 0; a < skyviewAlphaMask.length; a++) {
213            skyviewAlphaMask[a] = fullData.data[a * 4 + 3];
214          }
215        }
216        loaded++;
217        updateLoadingProgress(loaded, total);
218        if (loaded === total) onAllLayersLoaded();
219      };
220
221      img.onerror = function () {
222        // Fallback to PNG
223        if (img.src.indexOf('.webp') !== -1) {
224          img.src = ASSET_BASE + 'layers/' + fileSlug + '.png?v=' + LAYER_IMG_VERSION;
225        } else {
226          loaded++;
227          updateLoadingProgress(loaded, total);
228          if (loaded === total) onAllLayersLoaded();
229        }
230      };
231    });
232  }
233
234  function updateLoadingProgress(loaded, total) {
235    var pct = Math.round((loaded / total) * 100);
236    var bar = document.getElementById('intLoaderBar');
237    var txt = document.getElementById('intLoaderPct');
238    if (bar) bar.style.width = pct + '%';
239    if (txt) txt.textContent = pct;
240  }
241
242  function onAllLayersLoaded() {
243    // Load original full image as backdrop
244    var bgImg = new Image();
245    bgImg.src = ASSET_BASE + 'layers/original.webp';
246    bgImg.onload = function () {
247      baseCompositeCanvas = document.createElement('canvas');
248      baseCompositeCanvas.width = CANVAS_W;
249      baseCompositeCanvas.height = CANVAS_H;
250      baseCompositeCanvas.getContext('2d').drawImage(bgImg, 0, 0, CANVAS_W, CANVAS_H);
251      applyDefaultColors();
252      compositeAll();
253      finishInit();
254    };
255    bgImg.onerror = function () {
256      // Fallback: no backdrop
257      applyDefaultColors();
258      compositeAll();
259      finishInit();
260    };
261  }
262
263  function finishInit() {
264
265    // Hide loader
266    var loader = document.getElementById('intLoader');
267    if (loader) loader.classList.add('is-hidden');
268
269    // Bind UI events
270    bindEvents();
271
272    // Mobile scroll hint — show/hide based on scroll position
273    initScrollHint();
274  }
275
276  function initScrollHint() {
277    var scrollEl = document.querySelector('.int-panel__scroll');
278    var hintEl = document.getElementById('intScrollHint');
279    if (!scrollEl || !hintEl) return;
280
281    var checkScroll = function () {
282      var isScrollable = scrollEl.scrollHeight > scrollEl.clientHeight + 20;
283      var nearBottom = scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 50;
284      if (!isScrollable || nearBottom) {
285        hintEl.classList.add('is-hidden');
286      } else {
287        hintEl.classList.remove('is-hidden');
288      }
289    };
290
291    scrollEl.addEventListener('scroll', checkScroll, { passive: true });
292    // Check after layout settles
293    setTimeout(checkScroll, 200);
294    // Re-check on resize (orientation changes)
295    window.addEventListener('resize', debounce(checkScroll, 150));
296  }
297
298  /* ----------------------------------------------------------
299     RECOLORING ENGINE
300     ---------------------------------------------------------- */
301  function recolorPixels(imageData, targetR, targetG, targetB, lumStats) {
302    var d = imageData.data;
303    var targetLum = targetR * 0.299 + targetG * 0.587 + targetB * 0.114;
304
305    // Dark targets: enhanced contrast + specular highlights
306    if (targetLum < 80 && lumStats) {
307      recolorDarkTarget(d, targetR, targetG, targetB, targetLum, lumStats);
308      return imageData;
309    }
310
311    // Original algorithm for light/mid colors (unchanged)
312    var gamma, strength, boost;
313    if (targetLum < 120) {
314      gamma = 0.9; strength = 0.85; boost = 1.1;
315    } else {
316      gamma = 1.1; strength = 0.90; boost = 1.0;
317    }
318
319    for (var i = 0; i < d.length; i += 4) {
320      if (d[i + 3] === 0) continue;
321      var lum = (d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114) / 255;
322      var lumAdj = Math.min(Math.pow(lum, gamma) * boost, 1.0);
323      d[i]     = Math.min(255, lumAdj * targetR * strength + d[i]     * (1 - strength) * (targetR / 255));
324      d[i + 1] = Math.min(255, lumAdj * targetG * strength + d[i + 1] * (1 - strength) * (targetG / 255));
325      d[i + 2] = Math.min(255, lumAdj * targetB * strength + d[i + 2] * (1 - strength) * (targetB / 255));
326    }
327    return imageData;
328  }
329
330  /**
331   * Dark-target recoloring: normalizes source luminance, applies S-curve
332   * contrast enhancement, and adds specular highlights for realistic
333   * dark leather / fabric rendering.
334   */
335  function recolorDarkTarget(d, tR, tG, tB, targetLum, stats) {
336    var sMin = stats.min, sRange = stats.range;
337
338    // How dark: 0 (near threshold 80) → 1 (pure black)
339    var darkness = Math.max(0, 1.0 - targetLum / 80);
340
341    // Parameters: dark base + sharp specular peaks (black leather reference match)
342    var shadowDepth   = 2.0 + darkness * 1.0;       // deep shadow creases (gamma 3.0)
343    var highlightLift = 0.30;                        // sharp highlight concentration
344    var contrastBoost = 0.7 + darkness * 0.5;        // LOW base keeps surface dark (max 1.2)
345    var specPeak      = 0.25 + darkness * 0.35;      // STRONG specular at peaks (max 0.60)
346    var specStart     = 0.65;                         // only top ridges get specular shine
347
348    for (var i = 0; i < d.length; i += 4) {
349      if (d[i + 3] === 0) continue;
350
351      var lum = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114;
352      var norm = (lum - sMin) / sRange; // 0-1 normalized
353      if (norm < 0) norm = 0;
354      if (norm > 1) norm = 1;
355
356      // S-curve: deepen shadows, lift highlights
357      var curved;
358      if (norm < 0.5) {
359        var t = norm / 0.5;
360        curved = 0.5 * Math.pow(t, shadowDepth);
361      } else {
362        var t2 = (norm - 0.5) / 0.5;
363        curved = 0.5 + 0.5 * Math.pow(t2, highlightLift);
364      }
365
366      // Base color modulated by curved luminance
367      var baseFactor = curved * contrastBoost;
368
369      // Specular: blend toward white only at sharpest highlight peaks
370      var specAmount = 0;
371      if (curved > specStart) {
372        var specT = (curved - specStart) / (1.0 - specStart);
373        specAmount = specT * specT * specT * specPeak;  // cubic for sharper falloff
374      }
375
376      var invSpec = 1.0 - specAmount;
377      var oR = tR * baseFactor * invSpec + 255 * specAmount;
378      var oG = tG * baseFactor * invSpec + 255 * specAmount;
379      var oB = tB * baseFactor * invSpec + 255 * specAmount;
380
381      d[i]     = oR > 255 ? 255 : (oR < 0 ? 0 : (oR + 0.5) | 0);
382      d[i + 1] = oG > 255 ? 255 : (oG < 0 ? 0 : (oG + 0.5) | 0);
383      d[i + 2] = oB > 255 ? 255 : (oB < 0 ? 0 : (oB + 0.5) | 0);
384    }
385  }
386
387  // Layers with very dark source pixels need stronger recoloring.
388  // Layer 002 was re-mastered from the beige base (Asset2) so it now recolors
389  // identically to door_a/b via the standard path. Kept as empty array as a
390  // hook for future dark-source layers.
391  var DARK_SOURCE_LAYERS = [];
392
393  function recolorLayer(layerId, hex) {
394    if (!originalImageData[layerId]) return;
395    var rgb = hexToRgb(hex);
396    var original = originalImageData[layerId];
397    var copy = new ImageData(
398      new Uint8ClampedArray(original.data),
399      CANVAS_W, CANVAS_H
400    );
401    if (DARK_SOURCE_LAYERS.indexOf(layerId) !== -1) {
402      recolorDarkSource(copy, rgb.r, rgb.g, rgb.b, layerLumStats[layerId]);
403    } else {
404      recolorPixels(copy, rgb.r, rgb.g, rgb.b, layerLumStats[layerId]);
405    }
406    offscreenCanvases[layerId].getContext('2d').putImageData(copy, 0, 0);
407  }
408
409  /** For layers with compressed (dark) source luminance — stretches source range
410   *  to 0..1 via lumStats, then applies the same tone curve as re
410colorPixels so
411   *  output matches sibling layers (e.g. door_a/b) at the same target hex. */
412  function recolorDarkSource(imageData, targetR, targetG, targetB, lumStats) {
413    var d = imageData.data;
414    var sMin = lumStats.min, sRange = lumStats.range;
415    var targetLum = targetR * 0.299 + targetG * 0.587 + targetB * 0.114;
416
417    // Dark target: reuse recolorPixels' dark branch (already lum-normalized)
418    if (targetLum < 80) {
419      recolorDarkTarget(d, targetR, targetG, targetB, targetLum, lumStats);
420      return imageData;
421    }
422
423    // Light/mid target: mirror recolorPixels' curve on normalized luminance
424    var gamma, strength, boost;
425    if (targetLum < 120) { gamma = 0.9; strength = 0.85; boost = 1.1; }
426    else                  { gamma = 1.1; strength = 0.90; boost = 1.0; }
427
428    for (var i = 0; i < d.length; i += 4) {
429      if (d[i + 3] === 0) continue;
430      var lum = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114;
431      var norm = (lum - sMin) / sRange;
432      if (norm < 0) norm = 0; else if (norm > 1) norm = 1;
433      var virtualSrc = norm * 255;
434      var lumAdj = Math.min(Math.pow(norm, gamma) * boost, 1.0);
435      d[i]     = Math.min(255, lumAdj * targetR * strength + virtualSrc * (1 - strength) * (targetR / 255));
436      d[i + 1] = Math.min(255, lumAdj * targetG * strength + virtualSrc * (1 - strength) * (targetG / 255));
437      d[i + 2] = Math.min(255, lumAdj * targetB * strength + virtualSrc * (1 - strength) * (targetB / 255));
438    }
439    return imageData;
440  }
441
442  function compositeAll() {
443    mainCtx.clearRect(0, 0, CANVAS_W, CANVAS_H);
444    // Draw original composite as backdrop (fills gaps between layers)
445    if (baseCompositeCanvas) {
446      mainCtx.drawImage(baseCompositeCanvas, 0, 0);
447    }
448    // Draw recolored layers on top
449    for (var i = 0; i < LAYER_ORDER.length; i++) {
450      var osc = offscreenCanvases[LAYER_ORDER[i]];
451      if (osc) mainCtx.drawImage(osc, 0, 0);
452    }
453  }
454
455  function applyDefaultColors() {
456    var initial = INTERIOR_PRESETS.find(function (p) { return p.id === 'full_vanilla'; });
457    if (!initial) return;
458    COLOR_OPTIONS.forEach(function (optId) {
459      var hex = initial[optId];
460      if (hex) {
461        applyColor(optId, hex, true);
462        updateSwatchSelection(optId, hex);
463      }
464    });
465    if (initial.floor) {
466      loadFloorPattern(initial.floor);
467      updateFloorSelection(initial.floor);
468    }
469    updatePresetSelection('full_vanilla');
470  }
471
472  /* ----------------------------------------------------------
473     FLOOR PATTERN SWAP
474     ---------------------------------------------------------- */
475  function loadFloorPattern(patternId, callback) {
476    var img = new Image();
477    img.src = ASSET_BASE + 'floor/' + patternId + '.webp';
478    img.onload = function () {
479      var ctx = offscreenCanvases[1].getContext('2d');
480      ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
481      ctx.drawImage(img, 0, 0, CANVAS_W, CANVAS_H);
482      originalImageData[1] = ctx.getImageData(0, 0, CANVAS_W, CANVAS_H);
483      state.floor = patternId;
484      compositeAll();
485      if (callback) callback();
486    };
487    img.onerror = function () {
488      if (img.src.indexOf('.webp') !== -1) {
489        img.src = ASSET_BASE + 'floor/' + patternId + '.png';
490      }
491    };
492  }
493
494  /* ----------------------------------------------------------
495     SKYVIEW THEME SWAP
496     ---------------------------------------------------------- */
497  function applySkyview(themeId) {
498    var theme = SKYVIEW_THEMES.find(function (t) { return t.id === themeId; });
499    if (!theme || !skyviewAlphaMask) return;
500
501    state.skyview = themeId;
502    var ctx13 = offscreenCanvases[13].getContext('2d');
503
504    if (theme.type === 'original') {
505      // Restore original 013 pixels
506      var restore = new ImageData(new Uint8ClampedArray(skyviewOriginalData), CANVAS_W, CANVAS_H);
507      ctx13.putImageData(restore, 0, 0);
508      compositeAll();
509      updateSkyviewSelection(themeId);
510      return;
511    }
512
513    if (theme.type === 'image') {
514      applySkyviewFromImage(themeId, theme.src);
515      return;
516    }
517  }
518
519  function applySkyviewFromImage(themeId, imgSrc) {
520    if (!skyviewAlphaMask) return;
521    state.skyview = themeId;
522
523    var img = new Image();
524    img.src = imgSrc;
525    img.onload = function () {
526      // Find skylight bounding box from alpha mask
527      var bMinX = CANVAS_W, bMaxX = 0, bMinY = CANVAS_H, bMaxY = 0;
528      for (var py = 0; py < CANVAS_H; py++) {
529        for (var px = 0; px < CANVAS_W; px++) {
530          if (skyviewAlphaMask[py * CANVAS_W + px] > 10) {
531            if (px < bMinX) bMinX = px;
532            if (px > bMaxX) bMaxX = px;
533            if (py < bMinY) bMinY = py;
534            if (py > bMaxY) bMaxY = py;
535          }
536        }
537      }
538      var bw = bMaxX - bMinX, bh = bMaxY - bMinY;
539
540      var skyCanvas = document.createElement('canvas');
541      skyCanvas.width = CANVAS_W;
542      skyCanvas.height = CANVAS_H;
543      var skyCtx = skyCanvas.getContext('2d');
544
545      // Fit image to skylight bbox using contain (show more of image)
546      var iw = img.width, ih = img.height;
547      var scale = Math.max(bw / iw, bh / ih);
548      var dw = iw * scale, dh = ih * scale;
549      var dx = bMinX + (bw - dw) / 2;
550      var dy = bMinY + (bh - dh) / 2;
551      skyCtx.drawImage(img, dx, dy, dw, dh);
552
553      // Apply alpha mask
554      var skyData = skyCtx.getImageData(0, 0, CANVAS_W, CANVAS_H);
555      var sd = skyData.data;
556      for (var i = 0; i < skyviewAlphaMask.length; i++) {
557        sd[i * 4 + 3] = skyviewAlphaMask[i];
558      }
559
560      var ctx13 = offscreenCanvases[13].getContext('2d');
561      ctx13.clearRect(0, 0, CANVAS_W, CANVAS_H);
562      ctx13.putImageData(skyData, 0, 0);
563      compositeAll();
564      updateSkyviewSelection(themeId);
565    };
566  }
567
568  function updateSkyviewSelection(themeId) {
569    document.querySelectorAll('.int-skyview__item').forEach(function (el) {
570      el.classList.remove('is-selected');
571      if (el.getAttribute('data-skyview') === themeId) el.classList.add('is-selected');
572    });
573    var valEl = document.querySelector('.int-option[data-option="skyview"] .int-option__value');
574    if (valEl) {
575      var theme = SKYVIEW_THEMES.find(function (t) { return t.id === themeId; });
576      valEl.textContent = theme ? theme.name : themeId;
577    }
578  }
579
580  /* ----------------------------------------------------------
581     UI EVENT BINDING
582     ---------------------------------------------------------- */
583  function bindEvents() {
584    // Color swatches
585    document.querySelectorAll('.int-swatch[data-option][data-hex]').forEach(function (el) {
586      el.addEventListener('click', function () {
587        var optId = el.getAttribute('data-option');
588        var hex = el.getAttribute('data-hex');
589        applyColor(optId, hex);
590        updateSwatchSelection(optId, hex);
591      });
592    });
593
594    // Color picker trigger swatches
595    document.querySelectorAll('.int-swatch--picker').forEach(function (el) {
596      el.addEventListener('click', function () {
597        var optId = el.getAttribute('data-option');
598        openColorPicker(optId);
599      });
600    });
601
602    // Floor pattern items
603    document.querySelectorAll('.int-floor__item[data-floor]').forEach(function (el) {
604      el.addEventListener('click', function () {
605        var floorId = el.getAttribute('data-floor');
606        loadFloorPattern(floorId);
607        updateFloorSelection(floorId);
608      });
609    });
610
611    // Interior preset items
612    document.querySelectorAll('.int-floor__item[data-preset]').forEach(function (el) {
613      el.addEventListener('click', function () {
614        var presetId = el.getAttribute('data-preset');
615        applyPreset(presetId);
616      });
617    });
618
619    // Skyview themes
620    document.querySelectorAll('.int-skyview__item[data-skyview]').forEach(function (el) {
621      el.addEventListener('click', function () {
622        var themeId = el.getAttribute('data-skyview');
623        applySkyview(themeId);
624      });
625    });
626
627    // Theme cards
628    document.querySelectorAll('.int-themes__card[data-theme]').forEach(function (el) {
629      el.addEventListener('click', function () {
630        var themeId = el.getAttribute('data-theme');
631        applyTheme(themeId);
632      });
633    });
634
635    // Save buttons
636    var btnPng = document.getElementById('intSavePng');
637    var btnPdf = document.getElementById('intSavePdf');
638    if (btnPng) btnPng.addEventListener('click', savePNG);
639    if (btnPdf) btnPdf.addEventListener('click', savePDF);
640
641    // Color picker modal
642    var pickerInput = document.getElementById('intColorInput');
643    if (pickerInput) {
644      pickerInput.addEventListener('input', debounce(function () {
645        var hex = pickerInput.value;
646        document.getElementById('intPickerHex').textContent = hex.toUpperCase();
647      }, 30));
648    }
649
650    var applyBtn = document.getElementById('intPickerApply');
651    var cancelBtn = document.getElementById('intPickerCancel');
652    if (applyBtn) applyBtn.addEventListener('click', applyPickerColor);
653    if (cancelBtn) cancelBtn.addEventListener('click', closeColorPicker);
654
655    // Close picker on backdrop click
656    var modal = document.getElementById('intPickerModal');
657    if (modal) {
658      modal.addEventListener('click', function (e) {
659        if (e.target === modal) closeColorPicker();
660      });
661    }
662  }
663
664  /* ----------------------------------------------------------
665     COLOR APPLICATION — with interpolation transition
666     ---------------------------------------------------------- */
667  var activeTransitions = {};  // optionId → animationFrameId
668  var TRANSITION_DURATION = 350; // ms
669
670  function applyColor(optionId, hex, skipTransition) {
671    var opt = OPTION_MAP[optionId];
672    if (!opt) return;
673
674    var fromHex = state[optionId] || hex;
675    state[optionId] = hex;
676
677    // Update value label immediately
678    var valEl = document.querySelector('.int-option[data-option="' + optionId + '"] .int-option__value');
679    if (valEl) {
680      var preset = COLOR_PRESETS.find(function (p) { return p.hex === hex; });
681      valEl.textContent = preset ? preset.name : hex.toUpperCase();
682    }
683
684    // Skip transition on init or if same color
685    if (skipTransition || fromHex === hex) {
686      opt.layers.forEach(function (lid) { recolorLayer(lid, hex); });
687      compositeAll();
688      return;
689    }
690
691    // Cancel any running transition for this option
692    if (activeTransitions[optionId]) {
693      cancelAnimationFrame(activeTransitions[optionId]);
694    }
695
696    var fromRgb = hexToRgb(fromHex);
697    var toRgb = hexToRgb(hex);
698    var startTime = null;
699
700    function step(timestamp) {
701      if (!startTime) startTime = timestamp;
702      var elapsed = timestamp - startTime;
703      var t = Math.min(elapsed / TRANSITION_DURATION, 1);
704      // Ease-out cubic for smooth deceleration
705      var ease = 1 - Math.pow(1 - t, 3);
706
707      // Interpolate RGB
708      var curR = Math.round(fromRgb.r + (toRgb.r - fromRgb.r) * ease);
709      var curG = Math.round(fromRgb.g + (toRgb.g - fromRgb.g) * ease);
710      var curB = Math.round(fromRgb.b + (toRgb.b - fromRgb.b) * ease);
711      var curHex = rgbToHex(curR, curG, curB);
712
713      opt.layers.forEach(function (lid) { recolorLayer(lid, curHex); });
714      compositeAll();
715
716      if (t < 1) {
717        activeTransitions[optionId] = requestAnimationFrame(step);
718      } else {
719        delete activeTransitions[optionId];
720      }
721    }
722
723    activeTransitions[optionId] = requestAnimationFrame(step);
724  }
725
726  function updateSwatchSelection(optionId, hex) {
727    var container = document.querySelector('.int-option[data-option="' + optionId + '"]');
728    if (!container) return;
729    container.querySelectorAll('.int-swatch').forEach(function (sw) {
730      sw.classList.remove('is-selected');
731      if (sw.getAttribute('data-hex') === hex) sw.classList.add('is-selected');
732    });
733  }
734
735  function updateFloorSelection(patternId) {
736    var floorContainer = document.querySelector('.int-option[data-option="floor"]');
737    if (!floorContainer) return;
738    floorContainer.querySelectorAll('.int-floor__item[data-floor]').forEach(function (el) {
739      el.classList.remove('is-selected');
740      if (el.getAttribute('data-floor') === patternId) el.classList.add('is-selected');
741    });
742    var valEl = floorContainer.querySelector('.int-option__value');
743    if (valEl) {
744      var pat = FLOOR_PATTERNS.find(function (p) { return p.id === patternId; });
745      valEl.textContent = pat ? pat.name : patternId;
746    }
747  }
748
749  function updatePresetSelection(presetId) {
750    var container = document.querySelector('.int-option[data-option="preset"]');
751    if (!container) return;
752    container.querySelectorAll('.int-floor__item[data-preset]').forEach(function (el) {
753      el.classList.remove('is-selected');
754      if (el.getAttribute('data-preset') === presetId) el.classList.add('is-selected');
755    });
756    var valEl = container.querySelector('.int-option__value');
757    if (valEl) {
758      var p = INTERIOR_PRESETS.find(function (x) { return x.id === presetId; });
759      valEl.textContent = p ? p.name : presetId;
760    }
761  }
762
763  /* ----------------------------------------------------------
764     INTERIOR PRESETS — apply 9 colors + floor + selection state
765     ---------------------------------------------------------- */
766  // handle(008) & seatbelt(011) are stock parts — locked to ivory on every preset/theme change
767  var STOCK_IVORY_HEX = '#E8DEC8';
768  var STOCK_LOCKED_OPTIONS = ['handle', 'seatbelt'];
769
770  function applyPreset(presetId) {
771    var preset = INTERIOR_PRESETS.find(function (p) { return p.id === presetId; });
772    if (!preset) return;
773
774    COLOR_OPTIONS.forEach(function (optId) {
775      if (STOCK_LOCKED_OPTIONS.indexOf(optId) !== -1) {
776        applyColor(optId, STOCK_IVORY_HEX);
777        updateSwatchSelection(optId, STOCK_IVORY_HEX);
778        return;
779      }
780      var hex = preset[optId];
781      if (hex) {
782        applyColor(optId, hex);
783        updateSwatchSelection(optId, hex);
784      }
785    });
786
787    if (preset.floor && preset.floor !== state.floor) {
788      loadFloorPattern(preset.floor);
789    }
790    if (preset.floor) updateFloorSelection(preset.floor);
791
792    updatePresetSelection(presetId);
793  }
794
795  /* ----------------------------------------------------------
796     THEMES
797     ---------------------------------------------------------- */
798  function applyTheme(themeId) {
799    var theme = THEMES.find(function (t) { return t.id === themeId; });
800    if (!theme) return;
801
802    // Apply colors
803    COLOR_OPTIONS.forEach(function (optId) {
804      if (STOCK_LOCKED_OPTIONS.indexOf(optId) !== -1) {
805        applyColor(optId, STOCK_IVORY_HEX);
806        updateSwatchSelection(optId, STOCK_IVORY_HEX);
807        return;
808      }
809      var colorId = theme[optId];
810      var preset = findPreset(colorId);
811      if (preset) {
812        applyColor(optId, preset.hex);
813        updateSwatchSelection(optId, preset.hex);
814      }
815    });
816
817    // Floor is fixed — no pattern swap
818
819    // Update theme selection
820    document.querySelectorAll('.int-themes__card').forEach(function (el) {
821      el.classList.remove('is-selected');
822      if (el.getAttribute('data-theme') === themeId) el.classList.add('is-selected');
823    });
824  }
825
826  /* ----------------------------------------------------------
827     COLOR PICKER
828     ---------------------------------------------------------- */
829  function openColorPicker(optionId) {
830    state.activePickerOption = optionId;
831    var modal = document.getElementById('intPickerModal');
832    var input = document.getElementById('intColorInput');
833    var hexLabel = document.getElementById('intPickerHex');
834    var titleEl = document.getElementById('intPickerTitle');
835    if (input) input.value = state[optionId] || '#E8DEC8';
836    if (hexLabel) hexLabel.textContent = (state[optionId] || '#E8DEC8').toUpperCase();
837    if (titleEl) titleEl.textContent = OPTION_MAP[optionId].label + ' — 자유 컬러';
838    if (modal) modal.classList.add('is-open');
839  }
840
841  function closeColorPicker() {
842    var modal = document.getElementById('intPickerModal');
843    if (modal) modal.classList.remove('is-open');
844    state.activePickerOption = null;
845  }
846
847  function applyPickerColor() {
848    var input = document.getElementById('intColorInput');
849    if (!input || !state.activePickerOption) return;
850    var hex = input.value;
851    var optId = state.activePickerOption;
852    applyColor(optId, hex);
853    // Deselect all preset swatches, select picker
854    var container = document.querySelector('.int-option[data-option="' + optId + '"]');
855    if (container) {
856      container.querySelectorAll('.int-swatch').forEach(function (sw) {
857        sw.classList.remove('is-selected');
858      });
859      var pickerSw = container.querySelector('.int-swatch--picker');
860      if (pickerSw) pickerSw.classList.add('is-selected');
861    }
862    closeColorPicker();
863  }
864
865  /* ----------------------------------------------------------
866     SAVE — PNG
867     ---------------------------------------------------------- */
868  function savePNG() {
869    var dataUrl = mainCanvas.toDataURL('image/png');
870    var a = document.createElement('a');
871    a.download = 'BOGA9-interior-' + Date.now() + '.png';
872    a.href = dataUrl;
873    a.click();
874  }
875
876  /* ----------------------------------------------------------
877     SAVE — PDF
878     ---------------------------------------------------------- */
879  function savePDF() {
880    if (typeof window.jspdf === 'undefined') {
881      alert('PDF 라이브러리를 로딩 중입니다. 잠시 후 다시 시도해주세요.');
882      return;
883    }
884
885    var jsPDF = window.jspdf.jsPDF;
886    var imgData = mainCanvas.toDataURL('image/jpeg', 0.92);
887
888    var doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
889    var pageW = 297, pageH = 210;
890    var margin = 15;
891    var imgW = pageW - margin * 2;
892    var imgH = imgW * (CANVAS_H / CANVAS_W);
893
894    // Header
895    doc.setFontSize(10);
896    doc.setTextColor(100);
897    doc.text('BOGA9 INTERIOR STUDIO', margin, margin - 2);
898
899    // Image
900    doc.addImage(imgData, 'JPEG', margin, margin + 4, imgW, imgH);
901
902    // Options summary
903    var summaryY = margin + imgH + 10;
904    if (summaryY + 15 > pageH) {
905      doc.addPage();
906      summaryY = margin;
907    }
908    doc.setFontSize(8);
909    doc.setTextColor(80);
910    var labels = {
911      seat: 'Seat', seat_accent: 'Seat Accent', seatbelt: 'Seatbelt', handle: 'Handle',
912      door_a: 'Door A', door_b: 'Door B', door_c: 'Door C',
913      luggage: 'Luggage', ceiling: 'Ceiling', blind: 'Blind', floor: 'Floor'
914    };
915    var x = margin;
916    Object.keys(labels).forEach(function (key) {
917      var val = state[key];
918      doc.text(labels[key] + ': ' + val, x, summaryY);
919      x += 33;
920    });
921
922    // Footer
923    doc.setFontSize(7);
924    doc.setTextColor(150);
925    doc.text('boga-tec.co.kr | 1599-8088', margin, pageH - 5);
926    doc.text(new Date().toLocaleDateString('ko-KR'), pageW - margin, pageH - 5, { align: 'right' });
927
928    doc.save('BOGA9-interior-' + Date.now() + '.pdf');
929  }
930
931  /* ----------------------------------------------------------
932     UTILITIES
933     ---------------------------------------------------------- */
934  function hexToRgb(hex) {
935    hex = hex.replace('#', '');
936    return {
937      r: parseInt(hex.substring(0, 2), 16),
938      g: parseInt(hex.substring(2, 4), 16),
939      b: parseInt(hex.substring(4, 6), 16)
940    };
941  }
942
943  function rgbToHex(r, g, b) {
944    return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
945  }
946
947  function findPreset(colorId) {
948    return COLOR_PRESETS.find(function (p) { return p.id === colorId; }) || null;
949  }
950
951  function debounce(fn, ms) {
952    var timer;
953    return function () {
954      var ctx = this, args = arguments;
955      clearTimeout(timer);
956      timer = setTimeout(function () { fn.apply(ctx, args); }, ms);
957    };
958  }
959
960})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.