| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963 |
- /**
- * Export functionality for homeRoughEditor floorplan data
- * Allows saving floorplan data as JSON files
- */
- /**
- * Export the current floorplan data to a JSON file
- * @param {string} filename - Optional filename (without extension)
- * @param {boolean} includeMetadata - Whether to include additional metadata
- */
- function exportFloorplanJSON(filename = 'floorplan', includeMetadata = true) {
- try {
- // Prepare wall data for export (handle cyclic references)
- const wallDataForExport = [];
- for (let k in WALLS) {
- const wall = { ...WALLS[k] };
- // Convert parent/child references to indices for JSON serialization
- if (wall.child != null) {
- wall.child = WALLS.indexOf(wall.child);
- }
-
- if (wall.parent != null) {
- wall.parent = WALLS.indexOf(wall.parent);
- }
- wallDataForExport.push(wall);
- }
- // Prepare object data for export
- const objDataForExport = [];
- for (let k in OBJDATA) {
- const obj = { ...OBJDATA[k] };
- // Remove SVG graph references that can't be serialized
- delete obj.graph;
- objDataForExport.push(obj);
- }
- // Prepare room data for export
- const roomDataForExport = [...ROOM];
- // Create the export data structure
- const exportData = {
- version: "0.95",
- exportDate: new Date().toISOString(),
- data: {
- walls: wallDataForExport,
- objects: objDataForExport,
- rooms: roomDataForExport
- }
- };
- // Add metadata if requested
- if (includeMetadata) {
- exportData.metadata = {
- totalWalls: wallDataForExport.length,
- totalObjects: objDataForExport.length,
- totalRooms: roomDataForExport.length,
- totalArea: typeof globalArea !== 'undefined' ? (globalArea / 3600).toFixed(2) + ' m²' : 'Not calculated',
- settings: {
- wallSize: wallSize,
- partitionSize: partitionSize,
- meter: meter,
- grid: grid,
- colorSettings: {
- background: colorbackground,
- line: colorline,
- room: colorroom,
- wall: colorWall
- }
- }
- };
- }
- // Convert to JSON string
- const jsonString = JSON.stringify(exportData, null, 2);
- // Create and trigger download
- const blob = new Blob([jsonString], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
-
- const a = document.createElement('a');
- a.href = url;
- a.download = filename.endsWith('.json') ? filename : filename + '.json';
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
-
- // Clean up the URL object
- URL.revokeObjectURL(url);
- // Restore original wall references
- for (let k in WALLS) {
- if (WALLS[k].child != null && typeof WALLS[k].child === 'number') {
- WALLS[k].child = WALLS[WALLS[k].child];
- }
- if (WALLS[k].parent != null && typeof WALLS[k].parent === 'number') {
- WALLS[k].parent = WALLS[WALLS[k].parent];
- }
- }
- console.log('Floorplan exported successfully as:', a.download);
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Floorplan exported successfully');
- }
-
- return true;
- } catch (error) {
- console.error('Error exporting floorplan:', error);
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Export failed: ' + error.message);
- }
- return false;
- }
- }
- // ------------------------ Floorplan Mode (view floorplan under walls) ------------------------
- /**
- * Make walls translucent and trigger the same behavior as a double-click on the floorplan.
- * Useful for aligning to a background image.
- */
- function enterFloorplanMode() {
- try {
- // Dim only walls layer so underlying background remains visible
- const boxWall = document.getElementById('boxwall');
- if (boxWall) {
- boxWall.setAttribute('opacity', '0.35');
- boxWall.setAttribute('pointer-events', 'none');
- }
- // Dim identified room layers similarly
- const boxRoom = document.getElementById('boxRoom');
- if (boxRoom) {
- boxRoom.setAttribute('opacity', '0.35');
- boxRoom.setAttribute('pointer-events', 'none');
- }
- const boxSurface = document.getElementById('boxSurface');
- if (boxSurface) {
- boxSurface.setAttribute('opacity', '0.35');
- boxSurface.setAttribute('pointer-events', 'none');
- }
- // If a background image exists, show its tools (equivalent to double-clicking the image)
- const bgImg = document.getElementById('backgroundImage');
- if (bgImg && typeof showBackgroundImageTools === 'function') {
- showBackgroundImageTools();
- }
- window.__floorplanMode = true;
- if (typeof $ !== 'undefined') $('#boxinfo').html('Floorplan mode: walls translucent');
- // Update stored toggle button label if any
- if (window.__floorplanBtn && window.__floorplanBtn instanceof HTMLElement) {
- window.__floorplanBtn.innerText = 'Exit floorplan mode';
- }
- } catch (e) { console.error('enterFloorplanMode error:', e); }
- }
- /**
- * Restore normal wall opacity
- */
- function exitFloorplanMode() {
- try {
- const boxWall = document.getElementById('boxwall');
- if (boxWall) {
- boxWall.setAttribute('opacity', '1');
- // Explicitly restore interactivity
- boxWall.setAttribute('pointer-events', 'auto');
- }
- // Restore room layers opacity
- const boxRoom = document.getElementById('boxRoom');
- if (boxRoom) {
- boxRoom.setAttribute('opacity', '1');
- boxRoom.setAttribute('pointer-events', 'auto');
- }
- const boxSurface = document.getElementById('boxSurface');
- if (boxSurface) {
- boxSurface.setAttribute('opacity', '1');
- boxSurface.setAttribute('pointer-events', 'auto');
- }
- // Restore binder/highlight layer
- const boxBind = document.getElementById('boxbind');
- if (boxBind) {
- boxBind.removeAttribute('display');
- }
- // Hide background image tools if visible
- if (typeof hideBackgroundImageTools === 'function') {
- hideBackgroundImageTools();
- }
- window.__floorplanMode = false;
- if (typeof $ !== 'undefined') $('#boxinfo').html('Floorplan mode: off');
- // Update stored toggle button label if any
- if (window.__floorplanBtn && window.__floorplanBtn instanceof HTMLElement) {
- window.__floorplanBtn.innerText = 'Floorplan mode';
- }
- } catch (e) { console.error('exitFloorplanMode error:', e); }
- }
- /**
- * Toggle floorplan mode and update button label if present
- */
- function toggleFloorplanMode(btn) {
- // Remember the last-used toggle button for later label sync
- if (btn && btn instanceof HTMLElement) {
- window.__floorplanBtn = btn;
- }
- const on = !!window.__floorplanMode;
- if (on) {
- exitFloorplanMode();
- if (btn && btn instanceof HTMLElement) btn.innerText = 'Floorplan mode';
- } else {
- enterFloorplanMode();
- if (btn && btn instanceof HTMLElement) btn.innerText = 'Exit floorplan mode';
- }
- }
- /**
- * Toggle scaling mode and update button label if present
- */
- function toggleScalingMode(btn) {
- // Remember the last-used toggle button for later label sync
- if (btn && btn instanceof HTMLElement) {
- window.__scalingBtn = btn;
- }
- const on = !!window.__scalingMode;
- if (on) {
- exitScalingMode();
- if (btn && btn instanceof HTMLElement) btn.innerText = 'Scaling mode';
- } else {
- enterScalingMode();
- if (btn && btn instanceof HTMLElement) btn.innerText = 'Exit scaling mode';
- }
- }
- /**
- * Enter scaling mode - show scaling panel and calculate current dimensions
- */
- function enterScalingMode() {
- try {
- window.__scalingMode = true;
-
- // Hide other panels
- $('.leftBox').hide();
- $('#scalingTools').show();
-
- // Calculate current floorplan dimensions and store original state
- const bounds = calculateFloorplanBounds();
- const currentWidth = bounds.width;
- const currentHeight = bounds.height;
-
- console.log('Calculated bounds:', bounds);
- console.log('WALLS array:', WALLS);
-
- // Store original dimensions and bounds for reference
- window.__originalDimensions = { width: currentWidth, height: currentHeight };
- window.__originalBounds = bounds;
-
- // Store original wall coordinates for proper scaling
- if (WALLS && WALLS.length > 0) {
- window.__originalWalls = WALLS.map(wall => ({...wall}));
- } else {
- console.warn('No walls found for scaling');
- window.__originalWalls = [];
- }
-
- // Store original furniture positions if they exist
- if (window.OBJDATA) {
- window.__originalObjData = window.OBJDATA.map(obj => ({...obj}));
- }
-
- // Store relative positions of doors/windows on their walls
- if (OBJDATA) {
- window.__originalObjWallPositions = OBJDATA.map(obj => {
- if (typeof editor !== 'undefined' && editor.rayCastingWalls) {
- const wallBind = editor.rayCastingWalls(obj, WALLS);
- if (wallBind && wallBind.length > 0) {
- const wall = wallBind.length > 1 ? wallBind[wallBind.length - 1] : wallBind[0];
- if (wall) {
- // Calculate relative position along wall (0 = start, 1 = end)
- const wallLength = qSVG.measure(wall.start.x, wall.start.y, wall.end.x, wall.end.y);
- const objDistFromStart = qSVG.measure(wall.start.x, wall.start.y, obj.x, obj.y);
- const relativePos = wallLength > 0 ? objDistFromStart / wallLength : 0;
-
- return {
- wallIndex: WALLS.indexOf(wall),
- relativePosition: relativePos,
- originalSize: obj.size,
- originalThick: obj.thick
- };
- }
- }
- }
- return null;
- });
- }
-
- // Store aspect ratio for maintaining proportions
- window.__originalAspectRatio = currentWidth / currentHeight;
-
- // Use setTimeout to ensure DOM elements are accessible after panel is shown
- setTimeout(() => {
- // Use bounds calculation instead of measurement ribbons for more accurate dimensions
- const bounds = calculateFloorplanBounds();
- const widthInMeters = (bounds.width / meter);
- const heightInMeters = (bounds.height / meter);
-
- const widthInput = document.getElementById('floorplanWidth');
- const heightInput = document.getElementById('floorplanHeight');
- const originalDimSpan = document.getElementById('originalDimensions');
- const scaleFactorSpan = document.getElementById('scaleFactor');
-
- if (widthInput && heightInput && originalDimSpan && scaleFactorSpan) {
- // Round to match displayed measurements precision
- const displayWidth = Math.round(widthInMeters);
- const displayHeight = Math.round(heightInMeters);
-
- widthInput.value = displayWidth;
- heightInput.value = displayHeight;
- originalDimSpan.textContent = `${displayWidth}m × ${displayHeight}m`;
- scaleFactorSpan.textContent = '1.0';
-
- // Store exact dimensions for scaling calculations
- window.__originalDimensions = {
- width: bounds.width,
- height: bounds.height
- };
-
- console.log('Set input values from bounds calculation:', displayWidth, displayHeight);
- } else {
- console.error('Could not find scaling UI elements');
- }
- }, 500); // Increased delay to ensure measurements are rendered
-
- // Update button text
- if (window.__scalingBtn) {
- window.__scalingBtn.innerText = 'Exit scaling mode';
- }
-
- console.log('Entered scaling mode. Current dimensions:', currentWidth, 'x', currentHeight);
- } catch (e) {
- console.error('enterScalingMode error:', e);
- }
- }
- /**
- * Exit scaling mode - hide scaling panel and return to normal mode
- */
- function exitScalingMode() {
- try {
- window.__scalingMode = false;
-
- // Hide scaling panel
- $('#scalingTools').hide();
-
- // Show main panel
- $('#panel').show();
-
- // Update button text
- if (window.__scalingBtn) {
- window.__scalingBtn.innerText = 'Scaling mode';
- }
-
- // Clear stored dimensions and original data
- delete window.__originalDimensions;
- delete window.__originalBounds;
- delete window.__originalWalls;
- delete window.__originalObjData;
- delete window.__originalAspectRatio;
-
- console.log('Exited scaling mode');
- } catch (e) {
- console.error('exitScalingMode error:', e);
- }
- }
- /**
- * Calculate current floorplan bounds based on walls (no padding)
- */
- function calculateFloorplanBounds() {
- if (!WALLS || WALLS.length === 0) {
- return { width: 600, height: 400, minX: 0, minY: 0, maxX: 600, maxY: 400 };
- }
-
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
-
- // Find bounds from all wall coordinates (walls are objects with start/end properties)
- WALLS.forEach(wall => {
- if (wall && wall.start && wall.end) {
- const x1 = wall.start.x;
- const y1 = wall.start.y;
- const x2 = wall.end.x;
- const y2 = wall.end.y;
- minX = Math.min(minX, x1, x2);
- minY = Math.min(minY, y1, y2);
- maxX = Math.max(maxX, x1, x2);
- maxY = Math.max(maxY, y1, y2);
- }
- });
-
- // Handle case where no valid walls found
- if (minX === Infinity) {
- return { width: 600, height: 400, minX: 0, minY: 0, maxX: 600, maxY: 400 };
- }
-
- return {
- width: maxX - minX,
- height: maxY - minY,
- minX: minX,
- minY: minY,
- maxX: maxX,
- maxY: maxY
- };
- }
- /**
- * Update floorplan width while maintaining aspect ratio
- */
- function updateFloorplanWidth() {
- if (!window.__scalingMode) return;
-
- try {
- const newWidthM = parseFloat(document.getElementById('floorplanWidth').value);
-
- if (isNaN(newWidthM) || newWidthM <= 0) {
- return;
- }
-
- // Calculate current floorplan bounds
- const currentBounds = calculateFloorplanBounds();
- const currentWidthM = currentBounds.width / meter;
- const currentHeightM = currentBounds.height / meter;
-
- // Calculate scale factor based on width change
- const scaleFactor = newWidthM / currentWidthM;
-
- // Update height to maintain aspect ratio (both dimensions scale by same factor)
- const newHeightM = currentHeightM * scaleFactor;
- document.getElementById('floorplanHeight').value = newHeightM.toFixed(1);
-
- // Update scale factor display
- document.getElementById('scaleFactor').textContent = scaleFactor.toFixed(2);
-
- // Apply uniform scaling to all elements (same factor for X and Y)
- scaleAllElementsUniformly(scaleFactor);
-
- } catch (e) {
- console.error('updateFloorplanWidth error:', e);
- }
- }
- /**
- * Update floorplan height while maintaining aspect ratio
- */
- function updateFloorplanHeight() {
- if (!window.__scalingMode) return;
-
- try {
- const newHeightM = parseFloat(document.getElementById('floorplanHeight').value);
-
- if (isNaN(newHeightM) || newHeightM <= 0) {
- return;
- }
-
- // Calculate current floorplan bounds
- const currentBounds = calculateFloorplanBounds();
- const currentWidthM = currentBounds.width / meter;
- const currentHeightM = currentBounds.height / meter;
-
- // Calculate scale factor based on height change
- const scaleFactor = newHeightM / currentHeightM;
-
- // Update width to maintain aspect ratio (both dimensions scale by same factor)
- const newWidthM = currentWidthM * scaleFactor;
- document.getElementById('floorplanWidth').value = newWidthM.toFixed(1);
-
- // Update scale factor display
- document.getElementById('scaleFactor').textContent = scaleFactor.toFixed(2);
-
- // Apply uniform scaling to all elements (same factor for X and Y)
- scaleAllElementsUniformly(scaleFactor);
-
- } catch (e) {
- console.error('updateFloorplanHeight error:', e);
- }
- }
- /**
- * Scale all elements uniformly by the given factor (maintains shape)
- */
- function scaleAllElementsUniformly(scaleFactor) {
- try {
- // Calculate bounds and origin point for rigid scaling
- const bounds = calculateFloorplanBounds();
- const originX = bounds.minX;
- const originY = bounds.minY;
-
- console.log('Rigid scaling by factor:', scaleFactor, 'Origin:', originX, originY);
-
- WALLS.forEach(wall => {
- // Scale from fixed origin point (top-left) to maintain wall angles
- wall.start.x = originX + (wall.start.x - originX) * scaleFactor;
- wall.start.y = originY + (wall.start.y - originY) * scaleFactor;
-
- wall.end.x = originX + (wall.end.x - originX) * scaleFactor;
- wall.end.y = originY + (wall.end.y - originY) * scaleFactor;
- });
-
- // Restore doors/windows to their exact relative positions on scaled walls
- if (OBJDATA && window.__originalObjWallPositions) {
- OBJDATA.forEach((obj, index) => {
- const wallPos = window.__originalObjWallPositions[index];
- if (wallPos && wallPos.wallIndex >= 0 && WALLS[wallPos.wallIndex]) {
- const wall = WALLS[wallPos.wallIndex];
-
- // Scale size properties
- if (wallPos.originalSize !== undefined) {
- obj.size = wallPos.originalSize * scaleFactor;
- }
- if (wallPos.originalThick !== undefined) {
- obj.thick = wallPos.originalThick * scaleFactor;
- }
-
- // Calculate new position based on relative position along scaled wall
- const wallLength = qSVG.measure(wall.start.x, wall.start.y, wall.end.x, wall.end.y);
- const distFromStart = wallLength * wallPos.relativePosition;
-
- // Position object at exact relative position on wall
- const wallAngle = Math.atan2(wall.end.y - wall.start.y, wall.end.x - wall.start.x);
- obj.x = wall.start.x + Math.cos(wallAngle) * distFromStart;
- obj.y = wall.start.y + Math.sin(wallAngle) * distFromStart;
-
- // Recalculate limits for the new position and size
- if (wall.equations && typeof limitObj === 'function') {
- const newLimits = limitObj(wall.equations.base, obj.size, obj);
- if (Array.isArray(newLimits) && newLimits.length >= 2) {
- obj.limit = newLimits;
- }
- }
- }
- });
- }
-
- // Scale furniture from fixed origin point
- if (window.OBJDATA) {
- window.OBJDATA.forEach(obj => {
- if (obj) {
- // Scale position from fixed origin point
- obj.x = originX + (obj.x - originX) * scaleFactor;
- obj.y = originY + (obj.y - originY) * scaleFactor;
-
- // Scale furniture size
- if (obj.size !== undefined) {
- obj.size = obj.size * scaleFactor;
- }
- }
- });
- }
-
- // Clear all visual elements first
- $('#boxcarpentry').empty();
- $('#boxRib').empty();
-
- // Rebuild walls with new coordinates - this triggers wall equations recalculation
- if (typeof editor !== 'undefined' && editor.architect) {
- editor.architect(WALLS);
- }
-
- // Rebuild rooms with new wall positions
- if (typeof editor !== 'undefined' && editor.make_rooms) {
- editor.make_rooms();
- }
-
- // Rebuild all objects (doors, windows, furniture) with updated positions
- if (OBJDATA) {
- OBJDATA.forEach(obj => {
- if (obj && obj.update) {
- obj.update();
- if (obj.graph) {
- $('#boxcarpentry').append(obj.graph);
- }
- }
- });
- }
-
- // Rebuild furniture if it exists in window.OBJDATA
- if (window.OBJDATA) {
- window.OBJDATA.forEach(obj => {
- if (obj && obj.update) {
- obj.update();
- if (obj.graph) {
- $('#boxcarpentry').append(obj.graph);
- }
- }
- });
- }
-
- // Rebuild all measurements and scale bars - this updates the displayed dimensions
- if (typeof rib === 'function') {
- rib();
- }
-
- // Update individual wall measurements if available
- WALLS.forEach(wall => {
- if (typeof inWallRib === 'function') {
- inWallRib(wall, true); // true = append mode, don't clear existing
- }
- });
-
- // Update the top-level total width/height scale bars
- if (typeof editor !== 'undefined' && editor.showScaleBox) {
- editor.showScaleBox();
- }
-
- // Force save the new state
- if (typeof save === 'function') {
- save();
- }
-
- } catch (e) {
- console.error('scaleAllElementsUniformly error:', e);
- }
- }
- /**
- * Get the actual displayed dimensions by checking the measurement ribbons
- */
- function getActualFloorplanDimensions() {
- try {
- // Look for measurement text elements that show the actual dimensions
- const ribElements = document.querySelectorAll('#boxRib text');
- let maxWidth = 0;
- let maxHeight = 0;
-
- ribElements.forEach(element => {
- const text = element.textContent;
- if (text && text.includes('.')) {
- const value = parseFloat(text);
- if (!isNaN(value)) {
- // Determine if this is likely a width or height measurement
- const rect = element.getBoundingClientRect();
- if (rect.width > rect.height) {
- maxWidth = Math.max(maxWidth, value);
- } else {
- maxHeight = Math.max(maxHeight, value);
- }
- }
- }
- });
-
- // If no measurements found, fall back to bounds calculation
- if (maxWidth === 0 || maxHeight === 0) {
- const bounds = calculateFloorplanBounds();
- return {
- width: bounds.width / meter,
- height: bounds.height / meter
- };
- }
-
- return {
- width: maxWidth,
- height: maxHeight
- };
-
- } catch (e) {
- console.error('getActualFloorplanDimensions error:', e);
- const bounds = calculateFloorplanBounds();
- return {
- width: bounds.width / meter,
- height: bounds.height / meter
- };
- }
- }
- /**
- * Trigger AI import modal dialog for importing AI JSON with scaling option
- */
- function triggerAIImportDialog() {
- // Clear previous values and messages
- document.getElementById('ai_json_input').value = '';
- document.getElementById('ai_target_width').value = '';
- document.getElementById('ai_json_name').textContent = '';
- document.getElementById('ai_error_msg').textContent = '';
- document.getElementById('ai_success_msg').textContent = '';
- document.getElementById('ai_import_btn').disabled = true;
- // Set up file input event listener
- const fileInput = document.getElementById('ai_json_input');
- fileInput.addEventListener('change', function(event) {
- const file = event.target.files[0];
- if (file) {
- document.getElementById('ai_json_name').textContent = file.name;
- validateAIImportForm();
- } else {
- document.getElementById('ai_json_name').textContent = '';
- validateAIImportForm();
- }
- });
- // Set up width input event listener
- const widthInput = document.getElementById('ai_target_width');
- widthInput.addEventListener('input', validateAIImportForm);
- // Set up import button event listener
- const importBtn = document.getElementById('ai_import_btn');
- importBtn.addEventListener('click', function() {
- const file = fileInput.files[0];
- const targetWidth = parseFloat(widthInput.value);
-
- if (file && targetWidth > 0) {
- importAIFloorplanJSONWithScaling(file, targetWidth).then((success) => {
- if (success) {
- // Close modal on success
- const modal = bootstrap.Modal.getInstance(document.getElementById('aiImportModal'));
- if (modal) modal.hide();
- }
- });
- }
- });
- // Show the modal
- const modal = new bootstrap.Modal(document.getElementById('aiImportModal'));
- modal.show();
- }
- /**
- * Validate AI import form and enable/disable import button
- */
- function validateAIImportForm() {
- const file = document.getElementById('ai_json_input').files[0];
- const targetWidth = parseFloat(document.getElementById('ai_target_width').value);
- const importBtn = document.getElementById('ai_import_btn');
-
- const isValid = file && targetWidth > 0;
- importBtn.disabled = !isValid;
- }
- /**
- * Import AI floorplan JSON with automatic scaling to target width
- * @param {File} file
- * @param {number} targetWidthM - Target width in meters
- * @returns {Promise<boolean>}
- */
- function importAIFloorplanJSONWithScaling(file, targetWidthM) {
- return new Promise((resolve) => {
- if (!file) {
- console.error('No file provided for AI import');
- document.getElementById('ai_error_msg').textContent = 'No file selected for AI import';
- resolve(false);
- return;
- }
- if (!targetWidthM || targetWidthM <= 0) {
- document.getElementById('ai_error_msg').textContent = 'Please specify a valid target width';
- resolve(false);
- return;
- }
- const reader = new FileReader();
- reader.onload = function (e) {
- try {
- const jsonData = JSON.parse(e.target.result);
-
- // Validate the JSON structure
- if (!validateAIImportData(jsonData)) {
- document.getElementById('ai_error_msg').textContent = 'Invalid AI JSON format. Expected {"walls": [[x0,y0,x1,y1], ...]}';
- resolve(false);
- return;
- }
- // Clear current plan
- clearCurrentFloorplan();
- // Import walls first (without scaling)
- const importSuccess = importAIWallsData(jsonData);
- if (!importSuccess) {
- document.getElementById('ai_error_msg').textContent = 'Failed to import wall data';
- resolve(false);
- return;
- }
- // Calculate current floorplan bounds after import
- const currentBounds = calculateFloorplanBounds();
- const currentWidthM = currentBounds.width / meter;
-
- if (currentWidthM <= 0) {
- document.getElementById('ai_error_msg').textContent = 'Invalid floorplan dimensions after import';
- resolve(false);
- return;
- }
- // Calculate and apply scale factor
- const scaleFactor = targetWidthM / currentWidthM;
- scaleAllElementsUniformly(scaleFactor);
- // Save state
- if (typeof save === 'function') save();
- document.getElementById('ai_success_msg').textContent = `AI floorplan imported and scaled to ${targetWidthM}m width (scale factor: ${scaleFactor.toFixed(2)})`;
-
- if (typeof fonc_button === 'function') {
- try { fonc_button('select_mode'); } catch (e) { /* noop */ }
- }
-
- // Center the imported plan in view
- if (typeof centerFloorplanView === 'function') {
- try { centerFloorplanView(40); } catch (e) { /* noop */ }
- }
-
- resolve(true);
- } catch (err) {
- console.error('Error importing AI JSON:', err);
- document.getElementById('ai_error_msg').textContent = 'AI import failed: ' + err.message;
- resolve(false);
- }
- };
- reader.onerror = function () {
- console.error('Error reading AI JSON file');
- document.getElementById('ai_error_msg').textContent = 'Error reading AI JSON file';
- resolve(false);
- };
- reader.readAsText(file);
- });
- }
- /**
- * Import walls data from AI JSON format
- * @param {Object} jsonData - The parsed JSON data
- * @returns {boolean} - Success status
- */
- function importAIWallsData(jsonData) {
- try {
- // Create walls (skip malformed segments)
- const created = [];
- const defaultThick = typeof wallSize !== 'undefined' ? wallSize : 0.2;
- const isFiniteNum = (v) => typeof v === 'number' && isFinite(v);
- const dist2 = (a, b) => {
- const dx = a.x - b.x, dy = a.y - b.y; return dx * dx + dy * dy;
- };
- let skipped = 0;
-
- for (let i = 0; i < jsonData.walls.length; i++) {
- const seg = jsonData.walls[i];
- // Validate structure and values
- if (!Array.isArray(seg) || seg.length !== 4) { skipped++; continue; }
- const start = { x: seg[0], y: seg[1] };
- const end = { x: seg[2], y: seg[3] };
- if (!isFiniteNum(start.x) || !isFiniteNum(start.y) || !isFiniteNum(end.x) || !isFiniteNum(end.y)) { skipped++; continue; }
- // Reject zero-length or near-zero walls
- if (dist2(start, end) < 1e-10) { skipped++; continue; }
- try {
- const w = new editor.wall(start, end, 'normal', defaultThick);
- // Basic sanity on constructed wall
- if (!w || !w.start || !w.end || !isFiniteNum(w.start.x) || !isFiniteNum(w.start.y) || !isFiniteNum(w.end.x) || !isFiniteNum(w.end.y)) { skipped++; continue; }
- WALLS.push(w);
- created.push(w);
- } catch (e2) {
- skipped++;
- }
- }
- // Connect walls by matching endpoints (with small tolerance)
- const tol = 1e-3;
- const eq = (a, b) => (Math.abs(a.x - b.x) <= tol && Math.abs(a.y - b.y) <= tol);
- for (let i = 0; i < created.length; i++) {
- const wi = created[i];
- for (let j = 0; j < created.length; j++) {
- if (i === j) continue;
- const wj = created[j];
- if (!wi.parent && eq(wj.end, wi.start)) wi.parent = wj;
- if (!wi.child && eq(wj.start, wi.end)) wi.child = wj;
- if (wi.parent && wi.child) break;
- }
- }
- // Compute wall geometry first
- editor.architect(WALLS);
- // If doors/windows provided, place them
- try {
- if (Array.isArray(jsonData.doors) || Array.isArray(jsonData.windows)) {
- addOpeningsFromAI(jsonData);
- }
- } catch (openErr) {
- console.warn('Opening placement warning:', openErr);
- }
- return true;
- } catch (err) {
- console.error('Error importing AI walls data:', err);
- return false;
- }
- }
- /**
- * Import simple AI floorplan JSON with format: { "walls": [[x0,y0,x1,y1], ...] }
- * @param {File} file
- * @returns {Promise<boolean>}
- */
- function importAIFloorplanJSON(file) {
- return new Promise((resolve) => {
- if (!file) {
- console.error('No file provided for AI import');
- if (typeof $('#boxinfo') !== 'undefined') $('#boxinfo').html('No file selected for AI import');
- resolve(false);
- return;
- }
- const reader = new FileReader();
- reader.onload = function (e) {
- try {
- const jsonData = JSON.parse(e.target.result);
-
- // Validate the JSON structure
- if (!validateAIImportData(jsonData)) {
- if (typeof $('#boxinfo') !== 'undefined') $('#boxinfo').html('Invalid AI JSON format. Expected {"walls": [[x0,y0,x1,y1], ...]}');
- resolve(false);
- return;
- }
- // Clear current plan
- clearCurrentFloorplan();
- // Create walls (skip malformed segments)
- const created = [];
- const defaultThick = typeof wallSize !== 'undefined' ? wallSize : 0.2;
- const isFiniteNum = (v) => typeof v === 'number' && isFinite(v);
- const dist2 = (a, b) => {
- const dx = a.x - b.x, dy = a.y - b.y; return dx * dx + dy * dy;
- };
- let skipped = 0;
- for (let i = 0; i < jsonData.walls.length; i++) {
- const seg = jsonData.walls[i];
- // Validate structure and values
- if (!Array.isArray(seg) || seg.length !== 4) { skipped++; continue; }
- const start = { x: seg[0], y: seg[1] };
- const end = { x: seg[2], y: seg[3] };
- if (!isFiniteNum(start.x) || !isFiniteNum(start.y) || !isFiniteNum(end.x) || !isFiniteNum(end.y)) { skipped++; continue; }
- // Reject zero-length or near-zero walls
- if (dist2(start, end) < 1e-10) { skipped++; continue; }
- try {
- const w = new editor.wall(start, end, 'normal', defaultThick);
- // Basic sanity on constructed wall
- if (!w || !w.start || !w.end || !isFiniteNum(w.start.x) || !isFiniteNum(w.start.y) || !isFiniteNum(w.end.x) || !isFiniteNum(w.end.y)) { skipped++; continue; }
- WALLS.push(w);
- created.push(w);
- } catch (e2) {
- skipped++;
- }
- }
- // Connect walls by matching endpoints (with small tolerance)
- const tol = 1e-3;
- const eq = (a, b) => (Math.abs(a.x - b.x) <= tol && Math.abs(a.y - b.y) <= tol);
- for (let i = 0; i < created.length; i++) {
- const wi = created[i];
- for (let j = 0; j < created.length; j++) {
- if (i === j) continue;
- const wj = created[j];
- if (!wi.parent && eq(wj.end, wi.start)) wi.parent = wj;
- if (!wi.child && eq(wj.start, wi.end)) wi.child = wj;
- if (wi.parent && wi.child) break;
- }
- }
- // Compute wall geometry first
- editor.architect(WALLS);
- // If doors/windows provided, place them
- try {
- if (Array.isArray(jsonData.doors) || Array.isArray(jsonData.windows)) {
- addOpeningsFromAI(jsonData);
- }
- } catch (openErr) {
- console.warn('Opening placement warning:', openErr);
- }
- // Save state
- if (typeof save === 'function') save();
- if (typeof $('#boxinfo') !== 'undefined') {
- const msg = skipped > 0 ? `AI floorplan imported successfully (skipped ${skipped} malformed wall${skipped>1?'s':''})` : 'AI floorplan imported successfully';
- $('#boxinfo').html(msg);
- }
- if (typeof fonc_button === 'function') {
- try { fonc_button('select_mode'); } catch (e) { /* noop */ }
- }
- // Center the imported plan in view
- if (typeof centerFloorplanView === 'function') {
- try { centerFloorplanView(40); } catch (e) { /* noop */ }
- }
- resolve(true);
- } catch (err) {
- console.error('Error importing AI JSON:', err);
- if (typeof $('#boxinfo') !== 'undefined') $('#boxinfo').html('AI import failed: ' + err.message);
- resolve(false);
- }
- };
- reader.onerror = function () {
- console.error('Error reading AI JSON file');
- if (typeof $('#boxinfo') !== 'undefined') $('#boxinfo').html('Error reading AI JSON file');
- resolve(false);
- };
- reader.readAsText(file);
- });
- }
- /**
- * Validate AI import data
- * @param {Object} data
- */
- function validateAIImportData(data) {
- if (!data || !Array.isArray(data.walls)) return false;
- // walls: [[x0,y0,x1,y1], ...]
- for (let i = 0; i < data.walls.length; i++) {
- const seg = data.walls[i];
- if (!Array.isArray(seg) || seg.length !== 4) return false;
- for (let k = 0; k < 4; k++) {
- if (typeof seg[k] !== 'number' || !isFinite(seg[k])) return false;
- }
- }
- // Optional doors/windows: arrays of 8 numbers per rectangle
- const checkRects = (arr) => {
- if (!arr) return true;
- if (!Array.isArray(arr)) return false;
- for (let i = 0; i < arr.length; i++) {
- const r = arr[i];
- if (!Array.isArray(r) || r.length !== 8) return false;
- for (let k = 0; k < 8; k++) {
- if (typeof r[k] !== 'number' || !isFinite(r[k])) return false;
- }
- }
- return true;
- };
- if (!checkRects(data.doors)) return false;
- if (!checkRects(data.windows)) return false;
- return true;
- }
- // ------------------------ Helpers for AI openings placement ------------------------
- function addOpeningsFromAI(jsonData) {
- const rectsWithType = [];
- if (Array.isArray(jsonData.doors)) {
- for (const r of jsonData.doors) rectsWithType.push({ type: 'simple', rect: r });
- }
- if (Array.isArray(jsonData.windows)) {
- for (const r of jsonData.windows) rectsWithType.push({ type: 'fix', rect: r });
- }
- for (const item of rectsWithType) {
- const pts = [
- { x: item.rect[0], y: item.rect[1] },
- { x: item.rect[2], y: item.rect[3] },
- { x: item.rect[4], y: item.rect[5] },
- { x: item.rect[6], y: item.rect[7] }
- ];
- const center = {
- x: (pts[0].x + pts[1].x + pts[2].x + pts[3].x) / 4,
- y: (pts[0].y + pts[1].y + pts[2].y + pts[3].y) / 4
- };
- // Standardized sizes (pixels) based on meter scale
- const meterPx = 100;
- const standardDoorWidthPx = 0.6 * meterPx; // 0.6 m doors
- const standardWindowWidthPx = 0.8 * meterPx; // 0.8 m windows
- const minWindowSpacing = 0.2 * meterPx; // 0.2 m minimum spacing between windows
- // Find best wall by distance to the bounds midpoint (no intersection required)
- let best = null; // { wall, pos, widthAlongClamped, widthAlongDesired, angleSign }
- for (const wall of WALLS) {
- const wdx = wall.end.x - wall.start.x;
- const wdy = wall.end.y - wall.start.y;
- const wlen = Math.hypot(wdx, wdy);
- if (wlen < 1e-6) continue;
- const ux = wdx / wlen, uy = wdy / wlen; // along-wall unit
- const vx = -uy, vy = ux; // perpendicular unit
- // projections of corners along wall (s) and perpendicular (p)
- let sMin = Infinity, sMax = -Infinity;
- for (const P of pts) {
- const rx = P.x - wall.start.x, ry = P.y - wall.start.y;
- const s = rx * ux + ry * uy;
- if (s < sMin) sMin = s;
- if (s > sMax) sMax = s;
- }
- // center projection and closest point on wall
- const rcx = center.x - wall.start.x, rcy = center.y - wall.start.y;
- const sCenter = rcx * ux + rcy * uy;
- const pCenter = rcx * vx + rcy * vy;
- const t = Math.max(0, Math.min(wlen, sCenter));
- const closest = { x: wall.start.x + ux * t, y: wall.start.y + uy * t };
- // Distance from center to the wall segment
- const perpDist = Math.hypot(center.x - closest.x, center.y - closest.y);
- // Calculate available space for windows/doors
- const clamp = (v) => Math.max(0, Math.min(wlen, v));
- const sMinC = clamp(sMin);
- const sMaxC = clamp(sMax);
- const widthAlongClamped = Math.max(0, sMaxC - sMinC);
- const widthAlongDesired = (item.type === 'simple') ? standardDoorWidthPx : widthAlongClamped;
- const angleSign = (pCenter > 0) ? 1 : 0;
- const candidate = { wall, pos: { x: closest.x, y: closest.y, wall }, widthAlongClamped, widthAlongDesired, angleSign, score: perpDist, sMinC, sMaxC, ux, uy };
- if (!best || candidate.score < best.score) best = candidate;
- }
- if (!best) continue; // no walls available
- // For windows, create multiple default-sized windows instead of one large window
- if (item.type === 'fix') { // windows
- const availableSpace = best.widthAlongClamped;
- const numWindows = Math.floor(availableSpace / (standardWindowWidthPx + minWindowSpacing));
-
- if (numWindows > 0) {
- // Calculate total width needed for all windows and spacing
- const totalWindowWidth = numWindows * standardWindowWidthPx;
- const totalSpacingWidth = (numWindows - 1) * minWindowSpacing;
- const totalNeededWidth = totalWindowWidth + totalSpacingWidth;
-
- // Center the window group in the available space
- const startOffset = (availableSpace - totalNeededWidth) / 2;
-
- // Create each window
- for (let i = 0; i < numWindows; i++) {
- try {
- const wall = best.wall;
- const angleDeg = qSVG.angleDeg(wall.start.x, wall.start.y, wall.end.x, wall.end.y);
-
- // Calculate position for this window
- const windowOffset = best.sMinC + startOffset + (i * (standardWindowWidthPx + minWindowSpacing)) + (standardWindowWidthPx / 2);
- const windowPos = {
- x: wall.start.x + best.ux * windowOffset,
- y: wall.start.y + best.uy * windowOffset,
- wall: wall
- };
-
- const obj = new editor.obj2D('inWall', 'doorWindow', item.type, windowPos, 0, 0, standardWindowWidthPx, 'normal', wall.thick);
- let finalAngle = angleDeg;
- let sign = 0;
- if (best.angleSign === 1) { finalAngle += 180; sign = 1; }
- obj.x = windowPos.x;
- obj.y = windowPos.y;
- obj.angle = finalAngle;
- obj.angleSign = sign;
- // Limits along the wall
- let limits = limitObj(wall.equations.base, obj.size, windowPos);
- if (Array.isArray(limits)) {
- const onSeg = (pt) => qSVG.btwn(pt.x, wall.start.x, wall.end.x) && qSVG.btwn(pt.y, wall.start.y, wall.end.y);
- if (onSeg(limits[0]) && onSeg(limits[1])) {
- obj.limit = limits;
- }
- }
- OBJDATA.push(obj);
- if (typeof $ !== 'undefined') {
- $('#boxcarpentry').append(obj.graph);
- }
- obj.update();
- } catch (e) {
- console.warn('Failed to create window object:', e);
- }
- }
- }
- } else {
- // Original door logic (single door)
- try {
- const wall = best.wall;
- const angleDeg = qSVG.angleDeg(wall.start.x, wall.start.y, wall.end.x, wall.end.y);
- // Use standardized width for doors; clamped bounds width for windows
- // Ensure it fits the usable span on the wall; shrink if necessary
- const minWidthPx = 20; // don't create degenerate tiny openings
- const spanFit = Math.max(0, best.widthAlongClamped);
- let sizeForObj = Math.max(minWidthPx, Math.min(best.widthAlongDesired, spanFit));
- const obj = new editor.obj2D('inWall', 'doorWindow', item.type, best.pos, 0, 0, sizeForObj, 'normal', wall.thick);
- let finalAngle = angleDeg;
- let sign = 0;
- if (best.angleSign === 1) { finalAngle += 180; sign = 1; }
- obj.x = best.pos.x;
- obj.y = best.pos.y;
- obj.angle = finalAngle;
- obj.angleSign = sign;
- // Limits along the wall
- let limits = limitObj(wall.equations.base, obj.size, best.pos);
- if (Array.isArray(limits)) {
- // verify both points are on the segment
- const onSeg = (pt) => qSVG.btwn(pt.x, wall.start.x, wall.end.x) && qSVG.btwn(pt.y, wall.start.y, wall.end.y);
- // If limits are off the segment, clamp size to fit the segment span
- if (!(onSeg(limits[0]) && onSeg(limits[1]))) {
- // Compute clamped endpoints projected to the wall segment extents
- const clampToSeg = (pt) => ({
- x: qSVG.btwn(pt.x, wall.start.x, wall.end.x) ? pt.x : (Math.abs(pt.x - wall.start.x) < Math.abs(pt.x - wall.end.x) ? wall.start.x : wall.end.x),
- y: qSVG.btwn(pt.y, wall.start.y, wall.end.y) ? pt.y : (Math.abs(pt.y - wall.start.y) < Math.abs(pt.y - wall.end.y) ? wall.start.y : wall.end.y)
- });
- const c0 = clampToSeg(limits[0]);
- const c1 = clampToSeg(limits[1]);
- const clampedSpan = qSVG.measure(c0.x, c0.y, c1.x, c1.y);
- if (isFinite(clampedSpan) && clampedSpan > 0) {
- obj.size = Math.max(minWidthPx, Math.min(obj.size, clampedSpan));
- limits = limitObj(wall.equations.base, obj.size, best.pos);
- }
- }
- if (Array.isArray(limits)) {
- const onSeg2 = (pt) => qSVG.btwn(pt.x, wall.start.x, wall.end.x) && qSVG.btwn(pt.y, wall.start.y, wall.end.y);
- if (onSeg2(limits[0]) && onSeg2(limits[1])) {
- obj.limit = limits;
- // SNAP: set position exactly to the midpoint of the limits on the wall
- const mid = qSVG.middle(limits[0].x, limits[0].y, limits[1].x, limits[1].y);
- obj.x = mid.x;
- obj.y = mid.y;
- }
- }
- }
- OBJDATA.push(obj);
- if (typeof $ !== 'undefined') {
- $('#boxcarpentry').append(obj.graph);
- }
- obj.update();
- } catch (e) {
- console.warn('Failed to create opening object:', e);
- }
- }
- }
- // Refresh ribbons/indicators
- if (typeof rib === 'function') rib();
- }
- /**
- * Export floorplan data with custom options
- * @param {Object} options - Export configuration
- * @param {string} options.filename - Custom filename
- * @param {boolean} options.includeMetadata - Include metadata
- * @param {boolean} options.minified - Export minified JSON (no formatting)
- */
- function exportFloorplanCustom(options = {}) {
- const {
- filename = 'floorplan_' + new Date().toISOString().slice(0, 10),
- includeMetadata = true,
- minified = false
- } = options;
- try {
- // Prepare data similar to main export function
- const wallDataForExport = [];
- for (let k in WALLS) {
- const wall = { ...WALLS[k] };
- if (wall.child != null) {
- wall.child = WALLS.indexOf(wall.child);
- }
- if (wall.parent != null) {
- wall.parent = WALLS.indexOf(wall.parent);
- }
- wallDataForExport.push(wall);
- }
- const objDataForExport = [];
- for (let k in OBJDATA) {
- const obj = { ...OBJDATA[k] };
- delete obj.graph;
- objDataForExport.push(obj);
- }
- const exportData = {
- version: "0.95",
- exportDate: new Date().toISOString(),
- data: {
- walls: wallDataForExport,
- objects: objDataForExport,
- rooms: [...ROOM]
- }
- };
- if (includeMetadata) {
- exportData.metadata = {
- totalWalls: wallDataForExport.length,
- totalObjects: objDataForExport.length,
- totalRooms: ROOM.length,
- settings: {
- wallSize: wallSize,
- partitionSize: partitionSize,
- meter: meter,
- grid: grid
- }
- };
- }
- // Create JSON with or without formatting
- const jsonString = minified ?
- JSON.stringify(exportData) :
- JSON.stringify(exportData, null, 2);
- // Download the file
- const blob = new Blob([jsonString], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
-
- const a = document.createElement('a');
- a.href = url;
- a.download = filename.endsWith('.json') ? filename : filename + '.json';
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- // Restore wall references
- for (let k in WALLS) {
- if (WALLS[k].child !== null && typeof WALLS[k].child === 'number') {
- WALLS[k].child = WALLS[WALLS[k].child];
- }
- if (WALLS[k].parent !== null && typeof WALLS[k].parent === 'number') {
- WALLS[k].parent = WALLS[WALLS[k].parent];
- }
- }
- return true;
- } catch (error) {
- console.error('Error exporting floorplan:', error);
- return false;
- }
- }
- /**
- * Get floorplan data as JSON string (without downloading)
- * @param {boolean} includeMetadata - Whether to include metadata
- * @returns {string} JSON string of floorplan data
- */
- function getFloorplanJSON(includeMetadata = true) {
- try {
- // Prepare data for JSON conversion
- const wallDataForExport = [];
- for (let k in WALLS) {
- const wall = { ...WALLS[k] };
- if (wall.child != null) {
- wall.child = WALLS.indexOf(wall.child);
- }
- if (wall.parent != null) {
- wall.parent = WALLS.indexOf(wall.parent);
- }
- wallDataForExport.push(wall);
- }
- const objDataForExport = [];
- for (let k in OBJDATA) {
- const obj = { ...OBJDATA[k] };
- delete obj.graph;
- objDataForExport.push(obj);
- }
- const exportData = {
- version: "0.95",
- exportDate: new Date().toISOString(),
- data: {
- walls: wallDataForExport,
- objects: objDataForExport,
- rooms: [...ROOM]
- }
- };
- if (includeMetadata) {
- exportData.metadata = {
- totalWalls: wallDataForExport.length,
- totalObjects: objDataForExport.length,
- totalRooms: ROOM.length
- };
- }
- // Restore wall references
- for (let k in WALLS) {
- if (WALLS[k].child !== null && typeof WALLS[k].child === 'number') {
- WALLS[k].child = WALLS[WALLS[k].child];
- }
- if (WALLS[k].parent !== null && typeof WALLS[k].parent === 'number') {
- WALLS[k].parent = WALLS[WALLS[k].parent];
- }
- }
- return JSON.stringify(exportData, null, 2);
- } catch (error) {
- console.error('Error generating floorplan JSON:', error);
- return null;
- }
- }
- /**
- * Import floorplan data from a JSON file
- * @param {File} file - The JSON file to import
- * @returns {Promise<boolean>} - Success status
- */
- function importFloorplanJSON(file) {
- return new Promise((resolve, reject) => {
- if (!file) {
- console.error('No file provided for import');
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('No file selected for import');
- }
- resolve(false);
- return;
- }
- // Check if it's a JSON file
- if (!file.name.toLowerCase().endsWith('.json')) {
- console.error('File must be a JSON file');
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Please select a JSON file');
- }
- resolve(false);
- return;
- }
- const reader = new FileReader();
-
- reader.onload = function(e) {
- try {
- const jsonData = JSON.parse(e.target.result);
-
- // Validate the JSON structure
- if (!validateImportData(jsonData)) {
- console.error('Invalid floorplan JSON format');
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Invalid floorplan file format');
- }
- resolve(false);
- return;
- }
- // Load the data into the editor
- if (loadFloorplanData(jsonData)) {
- console.log('Floorplan imported successfully');
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Floorplan imported successfully!');
- }
- if (typeof fonc_button === 'function') {
- try { fonc_button('select_mode'); } catch (e) { /* noop */ }
- }
- resolve(true);
- } else {
- console.error('Failed to load floorplan data');
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Failed to load floorplan data');
- }
- resolve(false);
- }
-
- } catch (error) {
- console.error('Error parsing JSON file:', error);
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Error reading file: Invalid JSON');
- }
- resolve(false);
- }
- };
- reader.onerror = function() {
- console.error('Error reading file');
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Error reading file');
- }
- resolve(false);
- };
- reader.readAsText(file);
- });
- }
- /**
- * Validate the structure of imported JSON data
- * @param {Object} jsonData - The parsed JSON data
- * @returns {boolean} - Whether the data is valid
- */
- function validateImportData(jsonData) {
- // Check for required top-level structure
- if (!jsonData || typeof jsonData !== 'object') {
- return false;
- }
- // Check for data object
- if (!jsonData.data || typeof jsonData.data !== 'object') {
- return false;
- }
- const data = jsonData.data;
- // Check for required arrays
- if (!Array.isArray(data.walls) || !Array.isArray(data.objects) || !Array.isArray(data.rooms)) {
- return false;
- }
- // Basic validation of wall structure
- for (let wall of data.walls) {
- if (!wall.start || !wall.end || typeof wall.thick === 'undefined') {
- return false;
- }
- if (typeof wall.start.x === 'undefined' || typeof wall.start.y === 'undefined' ||
- typeof wall.end.x === 'undefined' || typeof wall.end.y === 'undefined') {
- return false;
- }
- }
- return true;
- }
- /**
- * Load floorplan data into the editor
- * @param {Object} jsonData - The validated JSON data
- * @returns {boolean} - Success status
- */
- function loadFloorplanData(jsonData) {
- try {
- // Clear existing data
- clearCurrentFloorplan();
- const data = jsonData.data;
- // Load walls
- WALLS = [];
- for (let wallData of data.walls) {
- WALLS.push(wallData);
- }
- // Restore wall parent/child references from indices
- for (let k in WALLS) {
- if (WALLS[k].child !== null && typeof WALLS[k].child === 'number') {
- WALLS[k].child = WALLS[WALLS[k].child];
- }
- if (WALLS[k].parent !== null && typeof WALLS[k].parent === 'number') {
- WALLS[k].parent = WALLS[WALLS[k].parent];
- }
- }
- // Load rooms
- ROOM = [];
- for (let roomData of data.rooms) {
- ROOM.push(roomData);
- }
- // Load objects
- OBJDATA = [];
- for (let objData of data.objects) {
- try {
- // Recreate the object using the editor's obj2D constructor
- let obj = new editor.obj2D(
- objData.family || 'free',
- objData.class || 'furniture',
- objData.type || 'default',
- { x: objData.x || 0, y: objData.y || 0 },
- objData.angle || 0,
- objData.angleSign || 0,
- objData.size || 60,
- objData.hinge || 'normal',
- objData.thick || 20,
- objData.value || null
- );
-
- // Restore additional properties
- if (objData.limit) obj.limit = objData.limit;
-
- OBJDATA.push(obj);
-
- // Add to appropriate SVG container
- if (obj.class === 'energy') {
- $('#boxEnergy').append(obj.graph);
- } else {
- $('#boxcarpentry').append(obj.graph);
- }
-
- obj.update();
- } catch (objError) {
- console.warn('Failed to load object:', objData, objError);
- // Continue loading other objects even if one fails
- }
- }
- // Rebuild the visual representation
- if (typeof editor !== 'undefined' && editor.architect) {
- editor.architect(WALLS);
- }
-
- if (typeof editor !== 'undefined' && editor.showScaleBox) {
- editor.showScaleBox();
- }
-
- if (typeof rib === 'function') {
- rib();
- }
- // Save the imported state to history.
- // Suppress save if background image element is not present but previous snapshot had one.
- if (typeof save === 'function') {
- try { if (typeof window !== 'undefined') window.__suppressSaveIfNoBg = true; } catch(_) {}
- save();
- }
- return true;
-
- } catch (error) {
- console.error('Error loading floorplan data:', error);
- return false;
- }
- }
- /**
- * Clear the current floorplan data
- */
- function clearCurrentFloorplan() {
- try {
- // Clear objects and their SVG elements
- for (let k in OBJDATA) {
- if (OBJDATA[k].graph && OBJDATA[k].graph.remove) {
- OBJDATA[k].graph.remove();
- }
- }
- OBJDATA = [];
- // Clear walls
- WALLS = [];
- // Clear rooms
- ROOM = [];
- // Clear SVG containers
- if (typeof $ !== 'undefined') {
- $('#boxwall').empty();
- $('#boxcarpentry').empty();
- $('#boxEnergy').empty();
- $('#boxRoom').empty();
- $('#boxArea').empty();
- $('#boxRib').empty();
- $('#boxText').empty();
- }
-
- } catch (error) {
- console.error('Error clearing floorplan:', error);
- }
- }
- /**
- * Trigger file input dialog for importing
- */
- function triggerImportDialog() {
- // Create a hidden file input element
- const fileInput = document.createElement('input');
- fileInput.type = 'file';
- fileInput.accept = '.json';
- fileInput.style.display = 'none';
-
- fileInput.addEventListener('change', function(event) {
- const file = event.target.files[0];
- if (file) {
- importFloorplanJSON(file).then(success => {
- // Clean up the temporary input element
- document.body.removeChild(fileInput);
- });
- } else {
- document.body.removeChild(fileInput);
- }
- });
-
- // Add to DOM and trigger click
- document.body.appendChild(fileInput);
- fileInput.click();
- }
- /**
- * Import an image file as background layer
- * @param {File} file - The image file to import
- * @returns {Promise<boolean>} - Success status
- */
- function importBackgroundImage(file) {
- return new Promise((resolve, reject) => {
- if (!file) {
- console.error('No file provided for image import');
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('No image file selected');
- }
- resolve(false);
- return;
- }
- // Check if it's an image file
- const validTypes = ['image/png', 'image/jpeg', 'image/jpg'];
- const validExtensions = ['.png', '.jpg', '.jpeg'];
- const fileName = file.name.toLowerCase();
-
- if (!validTypes.includes(file.type) && !validExtensions.some(ext => fileName.endsWith(ext))) {
- console.error('File must be a PNG or JPG image');
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Please select a PNG or JPG image file');
- }
- resolve(false);
- return;
- }
- const reader = new FileReader();
-
- reader.onload = function(e) {
- try {
- const imageDataUrl = e.target.result;
-
- // Add the image as a background layer
- if (addBackgroundImage(imageDataUrl, file.name)) {
- console.log('Background image imported successfully');
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Background image imported successfully!');
- }
- // Update UI: show filename and enable Floorplan mode button
- try {
- const nameEl = document.getElementById('floorplan_filename');
- if (nameEl) nameEl.textContent = file.name || '';
- const btn = document.getElementById('floorplan_mode_btn');
- if (btn) btn.disabled = false;
- } catch(_) {}
- resolve(true);
- } else {
- console.error('Failed to add background image');
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Failed to add background image');
- }
- resolve(false);
- }
-
- } catch (error) {
- console.error('Error processing image file:', error);
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Error processing image file');
- }
- resolve(false);
- }
- };
- reader.onerror = function() {
- console.error('Error reading image file');
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Error reading image file');
- }
- resolve(false);
- };
- reader.readAsDataURL(file);
- });
- }
- /**
- * Add an image as a background layer to the SVG
- * @param {string} imageDataUrl - The data URL of the image
- * @param {string} fileName - The original filename
- * @returns {boolean} - Success status
- */
- function addBackgroundImage(imageDataUrl, fileName) {
- try {
- // Capture existing image geometry if same image is being reloaded
- let previousGeometry = null;
- try {
- const existingEl = document.getElementById('backgroundImage');
- if (existingEl) {
- const prevHref = existingEl.getAttribute('href') || existingEl.getAttribute('xlink:href') || '';
- if (prevHref && imageDataUrl && prevHref === imageDataUrl) {
- previousGeometry = {
- x: parseFloat(existingEl.getAttribute('x')) || 0,
- y: parseFloat(existingEl.getAttribute('y')) || 0,
- width: parseFloat(existingEl.getAttribute('width')) || 0,
- height: parseFloat(existingEl.getAttribute('height')) || 0,
- opacity: parseFloat(existingEl.getAttribute('opacity'))
- };
- if (typeof console !== 'undefined' && console.debug) {
- console.debug('[addBackgroundImage] preserving previous geometry for same image', previousGeometry);
- }
- }
- }
- } catch (_) {}
- // Remove any existing background image
- removeBackgroundImage();
-
- // Create an image element in the SVG
- const svgElement = document.getElementById('lin');
- if (!svgElement) {
- console.error('SVG element not found');
- return false;
- }
- // Create image element
- const imageElement = document.createElementNS('http://www.w3.org/2000/svg', 'image');
- imageElement.setAttribute('id', 'backgroundImage');
-
- // Add double-click event handler
- imageElement.addEventListener('dblclick', function(e) {
- e.preventDefault();
- e.stopPropagation();
- showBackgroundImageTools();
- });
-
- // Add drag functionality
- let isDragging = false;
- let dragStartX = 0;
- let dragStartY = 0;
- let imageStartX = 0;
- let imageStartY = 0;
-
- imageElement.addEventListener('mousedown', function(e) {
- // Only enable dragging when background image tools are visible
- if (!document.getElementById('backgroundImageTools').style.display ||
- document.getElementById('backgroundImageTools').style.display === 'none') {
- return;
- }
-
- e.preventDefault();
- e.stopPropagation();
-
- isDragging = true;
- window.draggingBackgroundImage = true; // inform engine to pause scene panning
- dragStartX = e.clientX;
- dragStartY = e.clientY;
- imageStartX = parseFloat(imageElement.getAttribute('x')) || 0;
- imageStartY = parseFloat(imageElement.getAttribute('y')) || 0;
-
- imageElement.style.cursor = 'grabbing';
- });
-
- document.addEventListener('mousemove', function(e) {
- if (!isDragging) return;
-
- e.preventDefault();
-
- const deltaX = e.clientX - dragStartX;
- const deltaY = e.clientY - dragStartY;
-
- const newX = imageStartX + deltaX;
- const newY = imageStartY + deltaY;
-
- imageElement.setAttribute('x', newX);
- imageElement.setAttribute('y', newY);
- });
-
- document.addEventListener('mouseup', function(e) {
- if (isDragging) {
- isDragging = false;
- window.draggingBackgroundImage = false;
- updateImageCursor();
- }
- });
-
- // Function to update cursor based on tools panel visibility
- function updateImageCursor() {
- const toolsPanel = document.getElementById('backgroundImageTools');
- if (toolsPanel && toolsPanel.style.display !== 'none' &&
- window.getComputedStyle(toolsPanel).display !== 'none') {
- imageElement.style.cursor = 'grab';
- } else {
- imageElement.style.cursor = 'pointer';
- }
- }
-
- // Store the cursor update function for later use
- imageElement.updateCursor = updateImageCursor;
-
- // Set initial cursor
- updateImageCursor();
-
- // Do not show default size/position; wait for intrinsic probe to set them
- // Keep preserveAspectRatio but hide initially to avoid flashing at 0,0 1100x700
- imageElement.setAttribute('preserveAspectRatio', 'xMinYMin meet');
- imageElement.setAttribute('opacity', '0'); // will be restored after sizing
- // Mark that background image sizing is in progress to avoid premature snapshotting
- try { if (typeof window !== 'undefined') window.__bgSizing = true; } catch(_) {}
-
- // Add to the SVG, but after the grid and before other elements
- const boxGrid = document.getElementById('boxgrid');
- if (boxGrid && boxGrid.nextSibling) {
- svgElement.insertBefore(imageElement, boxGrid.nextSibling);
- } else {
- // Fallback: add as first child after defs
- const defs = svgElement.querySelector('defs');
- if (defs && defs.nextSibling) {
- svgElement.insertBefore(imageElement, defs.nextSibling);
- } else {
- svgElement.insertBefore(imageElement, svgElement.firstChild);
- }
- }
-
- // Attach a MutationObserver to trace attribute changes on the background image
- try {
- if (window._bgImgObserver) {
- try { window._bgImgObserver.disconnect(); } catch(_) {}
- }
- const attrsToWatch = ['x','y','width','height','opacity','href','xlink:href'];
- const observer = new MutationObserver((mutations) => {
- mutations.forEach(m => {
- if (m.type === 'attributes') {
- const name = m.attributeName;
- if (attrsToWatch.includes(name)) {
- const newVal = imageElement.getAttribute(name) || imageElement.getAttributeNS('http://www.w3.org/1999/xlink', name) || null;
- const oldVal = m.oldValue;
- if (typeof console !== 'undefined') {
- console.debug('[bgImage observe]', { name, oldVal, newVal });
- if (console.trace) console.trace('[bgImage attribute changed]');
- }
- }
- }
- });
- });
- observer.observe(imageElement, { attributes: true, attributeOldValue: true, attributeFilter: attrsToWatch });
- window._bgImgObserver = observer;
- } catch(_) { /* ignore observer errors */ }
-
- // Use the uploaded image's intrinsic size to set initial SVG image dimensions
- // so landscape/portrait are respected, and center it within the 1100x700 viewBox
- try {
- const probe = new Image();
- probe.onload = function() {
- const natW = probe.naturalWidth || 1100;
- const natH = probe.naturalHeight || 700;
- // Establish base viewBox size (SVG is 1100x700)
- const baseW = 1100;
- const baseH = 700;
- // Compute initial size preserving aspect ratio to fit within viewBox
- const scale = Math.min(baseW / natW, baseH / natH);
- const w = Math.max(1, Math.round(natW * scale));
- const h = Math.max(1, Math.round(natH * scale));
- // Centered position
- const cx = Math.round((baseW - w) / 2);
- const cy = Math.round((baseH - h) / 2);
- // Apply geometry; if reloading same image, preserve previous geometry
- if (previousGeometry) {
- const pw = previousGeometry.width > 0 ? previousGeometry.width : w;
- const ph = previousGeometry.height > 0 ? previousGeometry.height : h;
- const px = previousGeometry.x != null ? previousGeometry.x : cx;
- const py = previousGeometry.y != null ? previousGeometry.y : cy;
- imageElement.setAttribute('width', String(pw));
- imageElement.setAttribute('height', String(ph));
- imageElement.setAttribute('x', String(px));
- imageElement.setAttribute('y', String(py));
- } else {
- imageElement.setAttribute('width', String(w));
- imageElement.setAttribute('height', String(h));
- imageElement.setAttribute('x', String(cx));
- imageElement.setAttribute('y', String(cy));
- }
- if (imageElement.dataset) {
- const naturalAspect = natW / natH;
- imageElement.dataset.naturalAspect = String(naturalAspect);
- imageElement.dataset.aspectRatio = String(naturalAspect);
- }
- // Update stored reference geometry if present
- if (window.currentBackgroundImage) {
- window.currentBackgroundImage.x = cx;
- window.currentBackgroundImage.y = cy;
- window.currentBackgroundImage.width = w;
- window.currentBackgroundImage.height = h;
- }
- if (typeof console !== 'undefined' && console.debug) {
- console.debug('[addBackgroundImage] sized & centered', { natW, natH, baseW, baseH, w, h, cx, cy });
- }
- // Now set the image source; this will paint with the correct geometry
- imageElement.setAttribute('href', imageDataUrl);
- imageElement.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', imageDataUrl);
- // Reveal now that proper size/position are applied
- imageElement.setAttribute('opacity', String(previousGeometry && previousGeometry.opacity != null ? previousGeometry.opacity : 0.7));
- // If we didn't capture previousGeometry from an existing element, try restoring
- // geometry from the most recent HISTORY snapshot that matches this image by href or fileName.
- try {
- if (!previousGeometry && typeof localStorage !== 'undefined') {
- const histStr = localStorage.getItem('history');
- if (histStr) {
- const histArr = JSON.parse(histStr);
- for (let i = histArr.length - 1; i >= 0; i--) {
- try {
- const snap = JSON.parse(histArr[i]);
- if (snap && snap.backgroundImage) {
- const hrefMatch = (snap.backgroundImage.href === imageDataUrl);
- const nameMatch = (fileName && snap.backgroundImage.fileName && String(fileName).toLowerCase() === String(snap.backgroundImage.fileName).toLowerCase());
- if (hrefMatch || nameMatch) {
- const props = snap.backgroundImage;
- if (props.width != null) imageElement.setAttribute('width', props.width);
- if (props.height != null) imageElement.setAttribute('height', props.height);
- if (props.x != null) imageElement.setAttribute('x', props.x);
- if (props.y != null) imageElement.setAttribute('y', props.y);
- if (props.opacity != null) imageElement.setAttribute('opacity', props.opacity);
- if (typeof console !== 'undefined' && console.debug) {
- console.debug('[addBackgroundImage] restored geometry from HISTORY snapshot', { hrefMatch, nameMatch });
- }
- break;
- }
- }
- } catch(_) { /* skip malformed entry */ }
- }
- }
- }
- } catch(_) { /* ignore history restore errors */ }
- // Persist correct geometry now that it's set
- try {
- // Clear sizing guard before saving so save() proceeds
- if (typeof window !== 'undefined') window.__bgSizing = false;
- if (typeof save === 'function') save();
- } catch(_) { /* ignore */ }
- // If the background image tools are open, refresh their fields to reflect new geometry
- try {
- const tools = document.getElementById('backgroundImageTools');
- const visible = tools && tools.style.display !== 'none' && window.getComputedStyle(tools).display !== 'none';
- if (visible && typeof showBackgroundImageTools === 'function') {
- showBackgroundImageTools();
- }
- } catch(_) { /* ignore */ }
- };
- probe.src = imageDataUrl;
- } catch (e) {
- console.warn('Could not derive intrinsic image size:', e);
- try { if (typeof window !== 'undefined') window.__bgSizing = false; } catch(_) {}
- }
- // Store reference for later manipulation
- window.currentBackgroundImage = {
- element: imageElement,
- fileName: fileName,
- dataUrl: imageDataUrl
- };
- // Update UI: set filename display and enable Floorplan mode button
- try {
- const nameEl = document.getElementById('floorplan_filename');
- if (nameEl) nameEl.textContent = fileName || '';
- const btn = document.getElementById('floorplan_mode_btn');
- if (btn) btn.disabled = false;
- } catch(_) {}
- return true;
-
- } catch (error) {
- console.error('Error adding background image:', error);
- return false;
- }
- }
- /**
- * Remove the current background image
- */
- function removeBackgroundImage() {
- try {
- const existingImage = document.getElementById('backgroundImage');
- if (existingImage) {
- existingImage.remove();
- }
-
- // Clear the reference
- if (window.currentBackgroundImage) {
- delete window.currentBackgroundImage;
- }
- // Update UI: clear filename, disable Floorplan mode button, and exit mode if active
- try {
- const nameEl = document.getElementById('floorplan_filename');
- if (nameEl) nameEl.textContent = '';
- const btn = document.getElementById('floorplan_mode_btn');
- if (btn) {
- btn.disabled = true;
- btn.innerText = 'Floorplan mode';
- }
- if (window.__floorplanMode && typeof exitFloorplanMode === 'function') {
- exitFloorplanMode();
- }
- } catch(_) {}
- } catch (error) {
- console.error('Error removing background image:', error);
- }
- }
- /**
- * Trigger file input dialog for importing background images
- */
- function triggerImageImportDialog() {
- // Create a hidden file input element
- const fileInput = document.createElement('input');
- fileInput.type = 'file';
- fileInput.accept = 'image/png,image/jpeg,image/jpg,.png,.jpg,.jpeg';
- fileInput.style.display = 'none';
-
- fileInput.addEventListener('change', function(event) {
- const file = event.target.files[0];
- if (file) {
- importBackgroundImage(file).then(success => {
- // Clean up the temporary input element
- document.body.removeChild(fileInput);
- });
- } else {
- document.body.removeChild(fileInput);
- }
- });
-
- // Add to DOM and trigger click
- document.body.appendChild(fileInput);
- fileInput.click();
- }
- /**
- * Adjust background image properties
- * @param {Object} properties - Properties to adjust (x, y, width, height, opacity)
- */
- function adjustBackgroundImage(properties) {
- try {
- const imageElement = document.getElementById('backgroundImage');
- if (!imageElement) {
- console.warn('No background image to adjust');
- return false;
- }
- try { if (console && console.debug) console.debug('[adjustBackgroundImage] input', properties); } catch(_) {}
- const keys = ['x', 'y', 'width', 'height', 'opacity'];
- keys.forEach(key => {
- if (properties[key] !== undefined) imageElement.setAttribute(key, properties[key]);
- });
- try {
- if (console && console.debug) {
- console.debug('[adjustBackgroundImage] applied', {
- x: imageElement.getAttribute('x'),
- y: imageElement.getAttribute('y'),
- width: imageElement.getAttribute('width'),
- height: imageElement.getAttribute('height'),
- opacity: imageElement.getAttribute('opacity')
- });
- }
- } catch(_) {}
- return true;
-
- } catch (error) {
- console.error('Error adjusting background image:', error);
- return false;
- }
- }
- /**
- * Show the background image tools panel
- */
- function showBackgroundImageTools() {
- try {
- const imageElement = document.getElementById('backgroundImage');
- if (!imageElement) {
- console.warn('No background image found');
- return;
- }
-
- // Hide other panels and show background image tools
- $('.leftBox').hide();
- $('#backgroundImageTools').show(200);
-
- // Initialize size inputs based on current image properties
- const currentWidthPx = parseFloat(imageElement.getAttribute('width')) || 1100;
- const currentHeightPx = parseFloat(imageElement.getAttribute('height')) || 700;
- const currentOpacity = parseFloat(imageElement.getAttribute('opacity')) || 0.7;
- // Prefer intrinsic aspect ratio if available
- let aspect = undefined;
- if (imageElement.dataset && imageElement.dataset.aspectRatio) {
- aspect = parseFloat(imageElement.dataset.aspectRatio);
- }
- if (!isFinite(aspect) || aspect <= 0) {
- aspect = (currentWidthPx > 0 && currentHeightPx > 0)
- ? (currentWidthPx / currentHeightPx)
- : (1100 / 700);
- if (imageElement.dataset) imageElement.dataset.aspectRatio = String(aspect);
- }
- const pxPerMeter = 60; // editor scale
- const widthMInput = document.getElementById('backgroundImageWidthM');
- const heightMInput = document.getElementById('backgroundImageHeightM');
- const aspectInfo = document.getElementById('backgroundImageAspectInfo');
- if (widthMInput) widthMInput.value = (currentWidthPx / pxPerMeter).toFixed(2);
- if (heightMInput) heightMInput.value = (currentHeightPx / pxPerMeter).toFixed(2);
- if (aspectInfo) aspectInfo.textContent = `Aspect ratio: ${(aspect).toFixed(4)} (W/H)`;
- // Bind input handlers to maintain aspect ratio
- if (widthMInput) {
- widthMInput.oninput = function(e) {
- const img = document.getElementById('backgroundImage');
- if (!img) return;
- const curX = parseFloat(img.getAttribute('x')) || 0;
- const curY = parseFloat(img.getAttribute('y')) || 0;
- const ar = parseFloat(img.dataset.aspectRatio) || aspect;
- const wM = parseFloat(e.target.value);
- if (!isFinite(wM) || wM <= 0) return;
- const wPx = wM * pxPerMeter;
- const hPx = wPx / ar;
- img.setAttribute('width', String(wPx));
- img.setAttribute('height', String(hPx));
- img.setAttribute('x', String(curX));
- img.setAttribute('y', String(curY));
- if (heightMInput) heightMInput.value = (hPx / pxPerMeter).toFixed(2);
- };
- }
- if (heightMInput) {
- heightMInput.oninput = function(e) {
- const img = document.getElementById('backgroundImage');
- if (!img) return;
- const curX = parseFloat(img.getAttribute('x')) || 0;
- const curY = parseFloat(img.getAttribute('y')) || 0;
- const ar = parseFloat(img.dataset.aspectRatio) || aspect;
- const hM = parseFloat(e.target.value);
- if (!isFinite(hM) || hM <= 0) return;
- const hPx = hM * pxPerMeter;
- const wPx = hPx * ar;
- img.setAttribute('width', String(wPx));
- img.setAttribute('height', String(hPx));
- img.setAttribute('x', String(curX));
- img.setAttribute('y', String(curY));
- if (widthMInput) widthMInput.value = (wPx / pxPerMeter).toFixed(2);
- };
- }
- // Initialize opacity slider and label
- const opacityPercent = Math.round(currentOpacity * 100);
- const opacitySlider = document.getElementById('backgroundImageOpacitySlider');
- const opacityLabel = document.getElementById('backgroundImageOpacityVal');
- if (opacitySlider) opacitySlider.value = opacityPercent;
- if (opacityLabel) opacityLabel.textContent = opacityPercent;
- if (opacitySlider) {
- opacitySlider.oninput = function(e) {
- const v = parseInt(e.target.value, 10) || 70;
- setBackgroundImageOpacity(v);
- if (opacityLabel) opacityLabel.textContent = v;
- };
- }
-
- // Update info box
- if (typeof $('#boxinfo') !== 'undefined') {
- $('#boxinfo').html('Background image settings - drag to move');
- }
-
- // Update cursor to indicate draggable state
- if (imageElement.updateCursor) {
- imageElement.updateCursor();
- }
-
- } catch (error) {
- console.error('Error showing background image tools:', error);
- }
- }
- /**
- * Scale the background image
- * @param {number} scalePercent - Scale percentage (10-300)
- */
- function scaleBackgroundImage(scalePercent) {
- try {
- const imageElement = document.getElementById('backgroundImage');
- if (!imageElement) {
- return false;
- }
-
- // Calculate new dimensions based on scale
- const baseWidth = 1100;
- const baseHeight = 700;
- const scale = scalePercent / 100;
-
- const newWidth = baseWidth * scale;
- const newHeight = baseHeight * scale;
-
- // Update image dimensions
- imageElement.setAttribute('width', newWidth);
- imageElement.setAttribute('height', newHeight);
-
- // Update the stored reference if it exists
- if (window.currentBackgroundImage) {
- window.currentBackgroundImage.scale = scale;
- }
-
- return true;
-
- } catch (error) {
- console.error('Error scaling background image:', error);
- return false;
- }
- }
- /**
- * Set the background image opacity
- * @param {number} opacityPercent - Opacity percentage (0-100)
- */
- function setBackgroundImageOpacity(opacityPercent) {
- try {
- const imageElement = document.getElementById('backgroundImage');
- if (!imageElement) {
- return false;
- }
-
- const opacity = opacityPercent / 100;
- imageElement.setAttribute('opacity', opacity);
-
- // Update the stored reference if it exists
- if (window.currentBackgroundImage) {
- window.currentBackgroundImage.opacity = opacity;
- }
-
- return true;
-
- } catch (error) {
- console.error('Error setting background image opacity:', error);
- return false;
- }
- }
- /**
- * Hide the background image tools panel and update cursor
- */
- function hideBackgroundImageTools() {
- try {
- // Hide the tools panel
- $('#backgroundImageTools').hide(100);
- $('#panel').show(200);
-
- // Update cursor state for the background image
- const imageElement = document.getElementById('backgroundImage');
- if (imageElement && imageElement.updateCursor) {
- imageElement.updateCursor();
- }
-
- } catch (error) {
- console.error('Error hiding background image tools:', error);
- }
- }
- /**
- * Export floorplan data in Blender-compatible format
- * @param {string} filename - Optional filename (without extension)
- * @param {number} wallHeight - Wall height in meters (default: 2.8)
- * @param {number} wallThickness - Wall thickness in meters (default: 0.08)
- */
- function exportForBlender(filename = 'floorplan_blender', wallHeight = 2.8, wallThickness = 0.08) {
- try {
- // Initialize the Blender export data structure
- const blenderData = {
- wall_height: wallHeight,
- wall_thickness: wallThickness,
- floors: [],
- walls: [],
- doors: [],
- windows: [],
- styles: []
- };
- // First pass: collect all coordinates to calculate extents
- let minX = Infinity, maxX = -Infinity;
- let minY = Infinity, maxY = -Infinity;
- // Helper function to update extents
- function updateExtents(x, y) {
- minX = Math.min(minX, x);
- maxX = Math.max(maxX, x);
- minY = Math.min(minY, y);
- maxY = Math.max(maxY, y);
- }
- // Calculate extents from rooms
- for (let i = 0; i < ROOM.length; i++) {
- const room = ROOM[i];
- if (room.coords && room.coords.length > 0) {
- for (let j = 0; j < room.coords.length; j++) {
- const coord = room.coords[j];
- const x = coord.x / 60;
- const y = coord.y / 60;
- updateExtents(x, y);
- }
- }
- }
- // Calculate extents from walls
- for (let i = 0; i < WALLS.length; i++) {
- const wall = WALLS[i];
- if (wall.start && wall.end) {
- updateExtents(wall.start.x / 60, wall.start.y / 60);
- updateExtents(wall.end.x / 60, wall.end.y / 60);
- }
- }
- // Calculate extents from objects (doors and windows)
- for (let i = 0; i < OBJDATA.length; i++) {
- const obj = OBJDATA[i];
- if (obj.x !== undefined && obj.y !== undefined) {
- let x = obj.x / 60;
- let y = obj.y / 60;
-
- // Apply angle-based adjustments for extent calculation
- if (obj.angle !== undefined) {
- const angle = obj.angle;
- if (angle === 90) {
- x += 0.1;
- } else if (angle === 270) {
- x -= 0.1;
- } else if (angle === 180) {
- y += 0.1;
- } else if (angle === 0) {
- y -= 0.1;
- }
- }
-
- // Only include doors and windows in extent calculation
- if (obj.type === 'door' || obj.type === 'doorDouble' || obj.type === 'doorSliding' || obj.type === 'simple' ||
- obj.type === 'window' || obj.type === 'windowDouble' || obj.type === 'windowBay' || obj.type === 'fix') {
- updateExtents(x, y);
- }
- }
- }
- // Calculate extents from furniture items
- if (typeof FURNITURE_ITEMS !== 'undefined' && Array.isArray(FURNITURE_ITEMS)) {
- for (let i = 0; i < FURNITURE_ITEMS.length; i++) {
- const furniture = FURNITURE_ITEMS[i];
- if (furniture.x !== undefined && furniture.y !== undefined) {
- const x = furniture.x / 60;
- const y = furniture.y / 60;
- updateExtents(x, y);
- }
- }
- }
- // Calculate center offset
- const centerX = (minX + maxX) / 2;
- const centerY = (minY + maxY) / 2;
-
- console.log(`Floorplan extents: X[${minX.toFixed(2)}, ${maxX.toFixed(2)}], Y[${minY.toFixed(2)}, ${maxY.toFixed(2)}]`);
- console.log(`Center offset: [${centerX.toFixed(2)}, ${centerY.toFixed(2)}]`);
- // Convert rooms to floors array
- // Each room becomes an object with materials and a polygon of [x, y] coordinates
- for (let i = 0; i < ROOM.length; i++) {
- const room = ROOM[i];
- if (room.coords && room.coords.length > 0) {
- const roomPolygon = [];
- for (let j = 0; j < room.coords.length; j++) {
- const coord = room.coords[j];
- // Convert from editor coordinates to Blender coordinates and center around (0,0)
- // Scale down from pixels to meters (assuming 60 pixels = 1 meter based on grid)
- const x = parseFloat(((coord.x / 60) - centerX).toFixed(2));
- const y = parseFloat(((coord.y / 60) - centerY).toFixed(2));
- roomPolygon.push([x, y]);
- }
- // Ensure polygon is closed (last point equals first)
- if (roomPolygon.length > 0) {
- const first = roomPolygon[0];
- const last = roomPolygon[roomPolygon.length - 1];
- if (first[0] !== last[0] || first[1] !== last[1]) {
- roomPolygon.push([first[0], first[1]]);
- }
- blenderData.floors.push({
- floor_material: 'WoodFloor',
- ceiling_material: 'DefaultCeiling',
- points: roomPolygon
- });
- }
- }
- }
-
- // Convert walls to line segments
- // Each wall becomes [[start.x, start.y], [end.x, end.y]]
- for (let i = 0; i < WALLS.length; i++) {
- const wall = WALLS[i];
- if (wall.start && wall.end) {
- const startX = parseFloat(((wall.start.x / 60) - centerX).toFixed(2));
- const startY = parseFloat(((wall.start.y / 60) - centerY).toFixed(2));
- const endX = parseFloat(((wall.end.x / 60) - centerX).toFixed(2));
- const endY = parseFloat(((wall.end.y / 60) - centerY).toFixed(2));
-
- blenderData.walls.push([
- [startX, startY],
- [endX, endY]
- ]);
- }
- }
- // Classify walls
- const { internal, external } = classifyWalls(blenderData);
- // Attempt to stitch external walls into a single outline polygon
- const externalOutline = buildExternalPolygon(external, 0.03);
- // if (externalOutline && externalOutline.length >= 3) {
- // blenderData.external_outline = externalOutline;
- // }
- // Build internal outlines by joining segments (chains may be open)
- const internalOutlines = buildJoinedChains(internal, 0.03);
- // if (internalOutlines && internalOutlines.length > 0) {
- // blenderData.internal_outlines = internalOutlines;
- // }
- // Convert wall outlines into requested object structure
- blenderData.walls = [];
-
- // Create wall object with all outlines
- const wallObject = {
- material: "Walls",
- trim: "SquareTrim",
- trim_material: "Walls",
- points: []
- };
-
- if (externalOutline && externalOutline.length >= 2) {
- wallObject.points.push(externalOutline);
- }
- if (internalOutlines && internalOutlines.length > 0) {
- for (const outline of internalOutlines) {
- wallObject.points.push(outline);
- }
- }
-
- // Only add wall object if it has points
- if (wallObject.points.length > 0) {
- blenderData.walls.push(wallObject);
- }
- // Convert objects (doors and windows) to individual objects with asset, position, and rotation
- for (let i = 0; i < OBJDATA.length; i++) {
- const obj = OBJDATA[i];
- if (obj.x !== undefined && obj.y !== undefined) {
- let x = parseFloat((obj.x / 60).toFixed(2));
- let y = parseFloat((obj.y / 60).toFixed(2));
-
- // Adjust position based on angle property
- // if (obj.angle !== undefined) {
- // const angle = obj.angle;
- // if (angle === 90) {
- // x += 0.1;
- // } else if (angle === 270) {
- // x -= 0.1;
- // } else if (angle === 180) {
- // y += 0.1;
- // } else if (angle === 0) {
- // y -= 0.1;
- // }
-
- // // Round to 2 decimal places after adjustment
- // x = parseFloat(x.toFixed(2));
- // y = parseFloat(y.toFixed(2));
- // }
- // Apply center offset to position coordinates
- x = parseFloat((x - centerX).toFixed(2));
- y = parseFloat((y - centerY).toFixed(2));
- // Categorize objects based on their type
- if (obj.type === 'door' || obj.type === 'doorDouble' || obj.type === 'doorSliding' || obj.type === 'simple') {
- blenderData.doors.push({
- asset: 'OpenDoor',
- position: [x, y],
- rotation: obj.angle || 0
- });
- } else if (obj.type === 'window' || obj.type === 'windowDouble' || obj.type === 'windowBay' || obj.type === 'fix') {
- blenderData.windows.push({
- asset: 'WindowPanel',
- position: [x, y],
- rotation: obj.angle || 0
- });
- }
- }
- }
- // Convert furniture items to Blender format and wrap in styles
- const furnitureArray = [];
- if (typeof FURNITURE_ITEMS !== 'undefined' && Array.isArray(FURNITURE_ITEMS)) {
- for (let i = 0; i < FURNITURE_ITEMS.length; i++) {
- const furniture = FURNITURE_ITEMS[i];
- if (furniture.x !== undefined && furniture.y !== undefined) {
- // Convert position from pixels to meters and apply center offset
- const x = parseFloat(((furniture.x / 60) - centerX).toFixed(1));
- const y = parseFloat(((furniture.y / 60) - centerY).toFixed(1));
-
- // Get rotation (default to 0 if not specified)
- const rotation = (-furniture.rotation || 0) - 90;
-
- // Get on_ceiling property from furniture definition
- let onCeiling = false;
- if (typeof FURNITURE_DATA !== 'undefined' && Array.isArray(FURNITURE_DATA)) {
- const furnitureType = FURNITURE_DATA.find(f => f.id === furniture.furnitureId || f.type === furniture.type);
- if (furnitureType && furnitureType.on_ceiling !== undefined) {
- onCeiling = furnitureType.on_ceiling;
- }
- }
-
- // Use furnitureId as asset identifier, fallback to type if not available
- const asset = furniture.furnitureId || furniture.type || 'unknown';
-
- furnitureArray.push({
- asset: asset,
- position: [x, y],
- rotation: rotation,
- on_ceiling: onCeiling
- });
- }
- }
- }
-
- // Wrap furniture in styles structure
- if (furnitureArray.length > 0) {
- blenderData.styles.push({
- name: "furnished",
- furniture: furnitureArray
- });
- }
- // Convert to JSON string with custom formatting for compact coordinate arrays
- let jsonString = JSON.stringify(blenderData, null, '\t');
-
- // Post-process to make coordinate arrays more compact
- // Replace multi-line coordinate arrays with single-line format
- jsonString = jsonString.replace(/\[\s*\n\s*([\d.-]+),\s*\n\s*([\d.-]+)\s*\n\s*\]/g, '[$1, $2]');
-
- // Create and trigger download
- const blob = new Blob([jsonString], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = filename + '.json';
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- URL.revokeObjectURL(url);
-
- console.log('Blender export completed:', filename + '.json');
- return true;
-
- } catch (error) {
- console.error('Error exporting for Blender:', error);
- return false;
- }
- }
- /**
- * Classify wall segments into internal vs external using room polygons from blenderData.floors[*].points.
- * A wall is considered INTERNAL if points on both sides of its midpoint lie inside any room polygon.
- * Otherwise it is EXTERNAL.
- *
- * Coordinate system: expects the same centered, meter-scaled coords used by exportForBlender
- * - blenderData.floors: Array of floors -> [ { points: [ [x,y], ... ], ... }, ... ]
- * - blenderData.walls: Array of wall outlines/segments -> [ [ [x1,y1], [x2,y2] ], ... ] or arrays of points
- *
- * @param {Object} blenderData
- * @param {number} [epsilon=0.2] - Offset distance from midpoint in meters for inside/outside tests
- * @returns {{ internal: Array, external: Array }}
- */
- function classifyWalls(blenderData, epsilon = 0.2) {
- if (!blenderData || !Array.isArray(blenderData.walls) || !Array.isArray(blenderData.floors)) {
- console.warn('classifyWalls: Invalid blenderData structure.');
- return { internal: [], external: blenderData && blenderData.walls ? [...blenderData.walls] : [] };
- }
- // Ray-casting point-in-polygon test
- function pointInPolygon(point, polygon) {
- // polygon: [ [x,y], [x,y], ... ]
- let inside = false;
- for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
- const xi = polygon[i][0], yi = polygon[i][1];
- const xj = polygon[j][0], yj = polygon[j][1];
- const intersect = ((yi > point[1]) !== (yj > point[1])) &&
- (point[0] < (xj - xi) * (point[1] - yi) / ((yj - yi) || 1e-12) + xi);
- if (intersect) inside = !inside;
- }
- return inside;
- }
- function insideAnyRoom(pt) {
- for (let k = 0; k < blenderData.floors.length; k++) {
- const floor = blenderData.floors[k];
- const poly = floor && Array.isArray(floor.points) ? floor.points : null;
- if (Array.isArray(poly) && poly.length >= 3 && pointInPolygon(pt, poly)) {
- return true;
- }
- }
- return false;
- }
- const internal = [];
- const external = [];
- for (let i = 0; i < blenderData.walls.length; i++) {
- const seg = blenderData.walls[i];
- if (!Array.isArray(seg) || seg.length !== 2) {
- // Malformed segment, treat as external by default
- external.push(seg);
- continue;
- }
- const ax = seg[0][0], ay = seg[0][1];
- const bx = seg[1][0], by = seg[1][1];
- // Midpoint
- const mx = (ax + bx) / 2;
- const my = (ay + by) / 2;
- // Perpendicular unit vector (normal)
- const dx = bx - ax;
- const dy = by - ay;
- const len = Math.hypot(dx, dy) || 1e-12;
- // Normal vectors: ( -dy/len, dx/len ) and opposite
- const nx = -dy / len;
- const ny = dx / len;
- // Sample points on both sides of the wall
- const p1 = [mx + nx * epsilon, my + ny * epsilon];
- const p2 = [mx - nx * epsilon, my - ny * epsilon];
- const side1Inside = insideAnyRoom(p1);
- const side2Inside = insideAnyRoom(p2);
- if (side1Inside && side2Inside) {
- internal.push(seg);
- } else {
- external.push(seg);
- }
- }
- return { internal, external };
- }
- /**
- * Build a closed polygon by stitching wall segments whose endpoints meet within a tolerance.
- * Returns the largest closed loop found (by area) as an array of [x, y] points.
- * If no closed loop can be formed, returns null.
- *
- * @param {Array} segments - Array of segments: [ [ [x1,y1], [x2,y2] ], ... ]
- * @param {number} [tolerance=0.03] - Max distance between endpoints to be considered matching
- * @returns {Array|null}
- */
- function buildExternalPolygon(segments, tolerance = 0.03) {
- if (!Array.isArray(segments) || segments.length === 0) return null;
- // Utility: distance between points
- const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
- // Shoelace area (signed)
- function polygonArea(poly) {
- let area = 0;
- for (let i = 0, n = poly.length; i < n; i++) {
- const j = (i + 1) % n;
- area += poly[i][0] * poly[j][1] - poly[j][0] * poly[i][1];
- }
- return area / 2;
- }
- // Make a mutable copy of segments
- const pool = segments.map(s => [[s[0][0], s[0][1]], [s[1][0], s[1][1]]]);
- let bestLoop = null;
- let bestArea = 0;
- // Try building loops starting from each segment in the pool
- for (let startIdx = 0; startIdx < pool.length; startIdx++) {
- if (!pool[startIdx]) continue; // already consumed
- // Start a new chain
- let [a, b] = pool[startIdx];
- let chain = [a, b];
- pool[startIdx] = null; // consume
- // Extend chain forward until closed or stuck
- let guard = 0;
- while (guard++ < segments.length + 5) {
- const tail = chain[chain.length - 1];
- let found = false;
- for (let i = 0; i < pool.length; i++) {
- const seg = pool[i];
- if (!seg) continue;
- const sA = seg[0];
- const sB = seg[1];
- if (dist(tail, sA) <= tolerance) {
- chain.push(sB);
- pool[i] = null;
- found = true;
- break;
- } else if (dist(tail, sB) <= tolerance) {
- chain.push(sA);
- pool[i] = null;
- found = true;
- break;
- }
- }
- // Closed loop?
- if (dist(chain[chain.length - 1], chain[0]) <= tolerance && chain.length > 3) {
- // Remove duplicated last point if extremely close to first
- chain[chain.length - 1] = chain[0];
- // Compute area and keep the largest absolute-area loop
- const unique = dedupeConsecutive(chain);
- const clean = ensureClosed(unique);
- const cleanNoDup = clean.slice(0, clean.length - 1); // area expects no repeated final point
- const area = Math.abs(polygonArea(cleanNoDup));
- if (area > bestArea) {
- bestArea = area;
- bestLoop = cleanNoDup;
- }
- break;
- }
- if (!found) break; // stuck
- }
- }
- // Ensure closed loop by appending the first vertex at the end
- return bestLoop && bestLoop.length >= 3 ? [...bestLoop, bestLoop[0]] : null;
- // Remove consecutive duplicates (within tolerance)
- function dedupeConsecutive(points) {
- const out = [];
- for (let i = 0; i < points.length; i++) {
- if (i === 0 || dist(points[i], points[i - 1]) > tolerance / 4) {
- out.push(points[i]);
- }
- }
- return out;
- }
- // Ensure first == last (closed)
- function ensureClosed(points) {
- if (points.length === 0) return points;
- const first = points[0];
- const last = points[points.length - 1];
- if (dist(first, last) > tolerance) {
- return [...points, first];
- }
- return points;
- }
- }
- /**
- * Join segments into chains by matching endpoints within a tolerance.
- * Chains do NOT need to be closed; returns all chains of 2+ points.
- * Each chain is an array of [x,y] points in order.
- *
- * @param {Array} segments - [ [ [x1,y1], [x2,y2] ], ... ]
- * @param {number} [tolerance=0.03]
- * @returns {Array<Array<[number,number]>>}
- */
- function buildJoinedChains(segments, tolerance = 0.03) {
- if (!Array.isArray(segments) || segments.length === 0) return [];
- const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
-
- // Calculate deviation angle from current chain direction
- function calculateDeviationAngle(chain, currentPoint, nextPoint) {
- if (chain.length < 2) return 0; // No existing direction to compare
-
- // Get the current direction vector (from second-to-last to last point)
- const prevPoint = chain[chain.length - 2];
- const currentDir = [currentPoint[0] - prevPoint[0], currentPoint[1] - prevPoint[1]];
- const currentDirMag = Math.hypot(currentDir[0], currentDir[1]);
-
- if (currentDirMag < 1e-6) return 0; // Degenerate case
-
- // Get the proposed direction vector
- const nextDir = [nextPoint[0] - currentPoint[0], nextPoint[1] - currentPoint[1]];
- const nextDirMag = Math.hypot(nextDir[0], nextDir[1]);
-
- if (nextDirMag < 1e-6) return Math.PI; // Degenerate case - maximum deviation
-
- // Calculate angle between vectors using dot product
- const dot = (currentDir[0] * nextDir[0] + currentDir[1] * nextDir[1]) / (currentDirMag * nextDirMag);
- const clampedDot = Math.max(-1, Math.min(1, dot)); // Clamp to avoid numerical errors
- return Math.acos(clampedDot); // Return angle in radians (0 = straight, π = opposite)
- }
- // Initialize pool of unused segments
- const pool = segments.map(s => ({ pts: [[s[0][0], s[0][1]], [s[1][0], s[1][1]]], used: false }));
- const chains = [];
- for (let i = 0; i < pool.length; i++) {
- if (pool[i].used) continue;
- let [a, b] = pool[i].pts;
- let chain = [a, b];
- pool[i].used = true;
- let extended = true;
- let guard = 0;
- while (extended && guard++ < segments.length * 3) {
- extended = false;
- const head = chain[0];
- const tail = chain[chain.length - 1];
- // Try to extend at tail - prioritize straight connections
- let bestTailOption = null;
- let bestTailAngle = Infinity;
-
- for (let j = 0; j < pool.length; j++) {
- if (pool[j].used) continue;
- const [p, q] = pool[j].pts;
-
- if (dist(tail, p) <= tolerance) {
- const angle = calculateDeviationAngle(chain, tail, q);
- if (angle < bestTailAngle) {
- bestTailAngle = angle;
- bestTailOption = { index: j, nextPoint: q };
- }
- } else if (dist(tail, q) <= tolerance) {
- const angle = calculateDeviationAngle(chain, tail, p);
- if (angle < bestTailAngle) {
- bestTailAngle = angle;
- bestTailOption = { index: j, nextPoint: p };
- }
- }
- }
-
- if (bestTailOption) {
- chain.push(bestTailOption.nextPoint);
- pool[bestTailOption.index].used = true;
- extended = true;
- }
- // Try to extend at head if no tail extension - prioritize straight connections
- if (!extended) {
- let bestHeadOption = null;
- let bestHeadAngle = Infinity;
-
- for (let j = 0; j < pool.length; j++) {
- if (pool[j].used) continue;
- const [p, q] = pool[j].pts;
-
- if (dist(head, p) <= tolerance) {
- const angle = calculateDeviationAngle(chain.slice().reverse(), head, q);
- if (angle < bestHeadAngle) {
- bestHeadAngle = angle;
- bestHeadOption = { index: j, nextPoint: q };
- }
- } else if (dist(head, q) <= tolerance) {
- const angle = calculateDeviationAngle(chain.slice().reverse(), head, p);
- if (angle < bestHeadAngle) {
- bestHeadAngle = angle;
- bestHeadOption = { index: j, nextPoint: p };
- }
- }
- }
-
- if (bestHeadOption) {
- chain.unshift(bestHeadOption.nextPoint);
- pool[bestHeadOption.index].used = true;
- extended = true;
- }
- }
- }
- // Deduplicate consecutive near-equal points
- const cleaned = [];
- for (let k = 0; k < chain.length; k++) {
- if (k === 0 || dist(chain[k], chain[k - 1]) > tolerance / 5) cleaned.push(chain[k]);
- }
- if (cleaned.length >= 2) chains.push(cleaned);
- }
- return chains;
- }
|