147 lines
9.3 KiB
Python
147 lines
9.3 KiB
Python
"""Validated DWG 2000 writer using the project's existing LibreDWG tools.
|
|
|
|
No source/project files are modified. Conversion intermediates and reports stay beside the requested output.
|
|
"""
|
|
from collections import Counter
|
|
import contextlib
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import ezdxf
|
|
from ezdxf import recover, colors
|
|
|
|
ROOT=Path(__file__).resolve().parent
|
|
TOOLS=ROOT.parent/'pierzainer_ground/tools/libredwg'
|
|
|
|
def compatibility_angles(path):
|
|
# LibreDWG imports the linear angle from AcDbRotatedDimension whereas standard
|
|
# DXF places it in AcDbAlignedDimension. Mirror the value in the converter input only.
|
|
lines=path.read_text(encoding='cp949').splitlines()
|
|
pairs=list(zip(lines[::2],lines[1::2]));groups=[];group=[]
|
|
for code,value in pairs:
|
|
if code.strip()=='0' and group:groups.append(group);group=[]
|
|
group.append((code,value))
|
|
if group:groups.append(group)
|
|
output=[]
|
|
for group in groups:
|
|
angle=next((v for c,v in group if c.strip()=='50'),'0')
|
|
for c,v in group:
|
|
output.extend([c,v])
|
|
if group[0][1]=='DIMENSION' and c.strip()=='100' and v=='AcDbRotatedDimension':output.extend([' 50',angle])
|
|
path.write_text('\n'.join(output)+'\n',encoding='cp949')
|
|
|
|
def fingerprint(entity):
|
|
def point(p):return tuple(round(float(v),5) for v in p)
|
|
d=entity.dxf;t=entity.dxftype();link=tuple((tag.code,tag.value) for tag in entity.get_xdata('PZ_GA')) if entity.has_xdata('PZ_GA') else ();base=(t,d.layer,link)
|
|
if t=='LINE':return base+(point(d.start),point(d.end))
|
|
if t=='CIRCLE':return base+(point(d.center),round(d.radius,5))
|
|
if t=='LWPOLYLINE':return base+(bool(entity.closed),tuple(point(p[:2]) for p in entity.get_points()))
|
|
if t=='TEXT':return base+(d.text,point(d.insert),round(d.height,5),round(d.get('rotation',0),5),d.get('style','STANDARD').casefold())
|
|
if t=='DIMENSION':return base+(round(float(entity.get_measurement()),5),point(d.defpoint2),point(d.defpoint3))
|
|
if t in ('ATTDEF','ATTRIB'):return base+(d.tag,d.text,point(d.insert),round(d.height,5))
|
|
if t=='INSERT':return base+(d.name,point(d.insert),tuple(fingerprint(a) for a in entity.attribs))
|
|
if t=='HATCH':return base+(d.pattern_name,tuple(tuple(point(p[:2]) for p in loop.vertices) for loop in entity.paths if hasattr(loop,'vertices')),
|
|
tuple((round(line.angle,5),point(line.base_point),point(line.offset),tuple(round(v,5) for v in line.dash_length_items)) for line in entity.pattern.lines) if entity.pattern else ())
|
|
return base
|
|
|
|
def convert(source,output):
|
|
source=Path(source).resolve();output=Path(output).resolve()
|
|
if output.suffix.lower()!='.dwg' or not output.is_relative_to(ROOT):raise ValueError('DWG output must stay inside 도면작성')
|
|
if output.exists():raise ValueError('Existing DWG is preserved; choose another output file')
|
|
output.parent.mkdir(parents=True,exist_ok=True)
|
|
original=ezdxf.readfile(source);audit=original.audit()
|
|
if audit.errors:raise ValueError('Input DXF audit failed')
|
|
doc=original
|
|
# R2000 has no MATERIAL objects. LibreDWG used to turn the default material
|
|
# dictionary into invalid ACDBPLACEHOLDER entries that its own reader repairs.
|
|
# Remove unsupported metadata before encoding, preserving indexed colours.
|
|
materials=[e for e in doc.objects if e.dxftype()=='MATERIAL']
|
|
material_dictionary=doc.rootdict.get('ACAD_MATERIAL')
|
|
unsupported_handles={e.dxf.handle for e in materials}
|
|
if material_dictionary:unsupported_handles.add(material_dictionary.dxf.handle)
|
|
# The bundled writer's R2004 path drops entities. R2000 is used after a full round-trip check.
|
|
# R2000 uses indexed colours; retain the intended greys with the nearest ACI colour.
|
|
colour_count=0
|
|
for entity in doc.entitydb.values():
|
|
if entity.is_alive and getattr(entity,'rgb',None):
|
|
rgb=entity.rgb
|
|
nearest=min(range(1,256),key=lambda i:sum((a-b)**2 for a,b in zip(rgb,colors.aci2rgb(i))))
|
|
entity.dxf.color=nearest;entity.dxf.discard('true_color');colour_count+=1
|
|
doc.dxfversion='AC1015';doc.encoding='cp949';doc.header['$DWGCODEPAGE']='ANSI_949'
|
|
intermediate=output.with_suffix('.conversion.dxf');doc.saveas(intermediate)
|
|
expected={layout.name:Counter(fingerprint(e) for e in layout) for layout in doc.layouts}
|
|
expected_blocks={block.name:Counter(fingerprint(e) for e in block) for block in doc.blocks if not block.name.startswith('*')}
|
|
compatibility_angles(intermediate)
|
|
# Strip only converter-input metadata after ezdxf has finished serialization;
|
|
# ezdxf requires its default materials while saving even an R2000 document.
|
|
lines=intermediate.read_text(encoding='cp949').splitlines()
|
|
pairs=list(zip(lines[::2],lines[1::2]));groups=[];group=[]
|
|
for code,value in pairs:
|
|
if code.strip()=='0' and group:groups.append(group);group=[]
|
|
group.append((code,value))
|
|
if group:groups.append(group)
|
|
cleaned=[]
|
|
for group in groups:
|
|
handle=next((v for c,v in group if c.strip()=='5'),None)
|
|
if handle in unsupported_handles:continue
|
|
skip=False
|
|
for i,(code,value) in enumerate(group):
|
|
if skip:skip=False;continue
|
|
if code.strip()=='3' and value=='ACAD_MATERIAL':skip=True;continue
|
|
if code.strip()=='347':value='0'
|
|
cleaned.extend([code,value])
|
|
intermediate.write_text('\n'.join(cleaned)+'\n',encoding='cp949')
|
|
logs=[]
|
|
for args in [[str(TOOLS/'dxf2dwg.exe'),'--as','r2000','-o',str(output),str(intermediate)],
|
|
[str(TOOLS/'dwgread.exe'),'-O','DXF','-o',str(output.with_suffix('.roundtrip.dxf')),str(output)]]:
|
|
result=subprocess.run(args,capture_output=True,timeout=90)
|
|
logs.append(result.stderr.decode('utf-8','replace')[-12000:])
|
|
if not output.exists() or output.stat().st_size<100:raise RuntimeError('DWG writer did not create a valid file')
|
|
if output.read_bytes()[:6]!=b'AC1015':raise ValueError('Invalid DWG 2000 signature')
|
|
roundtrip,reader_audit=recover.readfile(output.with_suffix('.roundtrip.dxf'))
|
|
audit=roundtrip.audit()
|
|
if audit.errors:raise ValueError('DWG round-trip audit failed')
|
|
actual={layout.name:Counter(fingerprint(e) for e in layout) for layout in roundtrip.layouts}
|
|
differences=[]
|
|
for name,wanted in expected.items():
|
|
got=actual.get(name,Counter())
|
|
if wanted!=got:differences.append(dict(layout=name,missing=sum((wanted-got).values()),extra=sum((got-wanted).values())))
|
|
if differences:raise ValueError('DWG round-trip changed objects: '+json.dumps(differences))
|
|
for name,wanted in expected_blocks.items():
|
|
if name not in roundtrip.blocks or Counter(fingerprint(e) for e in roundtrip.blocks[name])!=wanted:raise ValueError('DWG block/attribute definition changed: '+name)
|
|
source_dimensions=sorted(float(e.get_measurement()) for e in doc.modelspace().query('DIMENSION'))
|
|
target_dimensions=sorted(float(e.get_measurement()) for e in roundtrip.modelspace().query('DIMENSION'))
|
|
max_error=max((abs(a-b) for a,b in zip(source_dimensions,target_dimensions)),default=0)
|
|
if max_error>1e-6:raise ValueError('DWG dimension measurement differs by more than 0.000001mm')
|
|
for layer in doc.layers:
|
|
target=roundtrip.layers.get(layer.dxf.name)
|
|
if layer.dxf.lineweight!=target.dxf.lineweight:raise ValueError('DWG layer lineweight changed')
|
|
for style in doc.dimstyles:
|
|
target=roundtrip.dimstyles.get(style.dxf.name)
|
|
for key in ['dimtxt','dimasz','dimlfac','dimscale','dimexe','dimexo']:
|
|
if abs(float(style.dxf.get(key,0))-float(target.dxf.get(key,0)))>1e-6:raise ValueError('DWG dimension style changed: '+style.dxf.name+'/'+key)
|
|
for style in doc.styles:
|
|
if style.dxf.font.casefold()!=roundtrip.styles.get(style.dxf.name).dxf.font.casefold():raise ValueError('DWG text font changed')
|
|
for layout in doc.layouts:
|
|
target=roundtrip.layouts.get(layout.name)
|
|
for key in ['paper_width','paper_height','scale_numerator','scale_denominator','plot_layout_flags']:
|
|
if layout.dxf.get(key)!=target.dxf.get(key):raise ValueError('DWG paper/plot setting changed: '+key)
|
|
report=dict(status='validated',format='DWG 2000 / AC1015',sha256=hashlib.sha256(output.read_bytes()).hexdigest(),
|
|
layouts={l.name:len(l) for l in roundtrip.layouts},dimensions=len(roundtrip.modelspace().query('DIMENSION')),
|
|
object_fingerprints_match=True,source_links_match=True,dimension_measurements_match=True,max_dimension_error_mm=max_error,lineweights_match=True,dimstyles_match=True,paper_plot_settings_match=True,font_styles_match=True,audit_errors=len(audit.errors),
|
|
decoder_metadata_repairs=[str(e.message) for e in reader_audit.fixes],indexed_colour_conversions=colour_count,
|
|
native_autocad_open_tested=False)
|
|
report['unsupported_materials_removed']=len(materials)
|
|
output.with_suffix('.validation.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
|
|
output.with_suffix('.conversion.log').write_text('\n'.join(logs),encoding='utf-8')
|
|
return report
|
|
|
|
if __name__=='__main__':
|
|
try:print(json.dumps(convert(sys.argv[1],sys.argv[2]),ensure_ascii=False))
|
|
except Exception as error:
|
|
print(json.dumps(dict(status='error',message=str(error)),ensure_ascii=False));sys.exit(1)
|